mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): author avatar chips on user messages (incl. queued) (#111309)
* feat(ui): author avatar chips on user messages with gravatar and initials fallback Renders a small author avatar on user-authored chat messages, including queued/pending sends, so multi-user gateways show at a glance who wrote which prompt. Resolution: profile avatar URL (when available) -> Gravatar by SHA-256 email hash (d=404) -> deterministic initials with stable color. Assistant/tool output stays unmarked, and messages without sender identity render unchanged, keeping single-user gateways visually identical. * fix(ui): clear avatar fallback state on image load; document gravatar tradeoff * fix(ui): gate email-hash avatars behind a gateway proxy base URL Without a same-origin proxy base the email tier is disabled outright, so rendering a transcript can never disclose hashed sender emails or viewer IPs to a third-party avatar host. The gateway-side proxy route (follow-up) supplies the base and becomes the only avatar fetch path. * fix(ui): restrict profile avatar URLs to same-origin relative paths * fix(ui): validate profile avatar URLs with the URL parser, not string prefixes Backslashes normalize to slashes and tabs/newlines are stripped by browser URL parsing, so startsWith checks can be bypassed into cross-origin fetches. Resolving against a probe origin and requiring the origin to survive is parser-equivalent and closes the class.
This commit is contained in:
committed by
GitHub
parent
e2ca148d4b
commit
ba06fe1541
@@ -361,6 +361,10 @@ export class GatewayBrowserClient {
|
||||
});
|
||||
}
|
||||
|
||||
get instanceId(): string | undefined {
|
||||
return this.opts.instanceId;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.client.start();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Chat message types for the UI layer.
|
||||
*/
|
||||
|
||||
import type { SenderIdentity } from "./sender-label.ts";
|
||||
|
||||
export type ChatAttachment = {
|
||||
id: string;
|
||||
dataUrl?: string;
|
||||
@@ -41,6 +43,7 @@ export type ChatQueueItem = {
|
||||
sendRequestStartedAtMs?: number;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
sender?: SenderIdentity;
|
||||
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision;
|
||||
};
|
||||
|
||||
@@ -89,6 +92,7 @@ export type MessageGroup = {
|
||||
key: string;
|
||||
role: string;
|
||||
senderLabel?: string | null;
|
||||
sender?: SenderIdentity;
|
||||
messages: Array<{ message: unknown; key: string; duplicateCount?: number }>;
|
||||
timestamp: number;
|
||||
isStreaming: boolean;
|
||||
@@ -126,6 +130,7 @@ export type NormalizedMessage = {
|
||||
timestamp: number;
|
||||
id?: string;
|
||||
senderLabel?: string | null;
|
||||
sender?: SenderIdentity;
|
||||
audioAsVoice?: boolean;
|
||||
replyTarget?:
|
||||
| {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCurrentUserIdentity } from "./current-user-identity.ts";
|
||||
|
||||
describe("resolveCurrentUserIdentity", () => {
|
||||
it("selects only this browser connection's presence identity", () => {
|
||||
const hello = {
|
||||
snapshot: {
|
||||
presence: [
|
||||
{ instanceId: "other-browser", user: { id: "other@example.com" } },
|
||||
{
|
||||
instanceId: "this-browser",
|
||||
user: {
|
||||
id: "alice@example.com",
|
||||
name: "Alice Example",
|
||||
avatarUrl: "/avatars/alice.png",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveCurrentUserIdentity(hello, "this-browser")).toEqual({
|
||||
id: "alice@example.com",
|
||||
name: "Alice Example",
|
||||
profileAvatarUrl: "/avatars/alice.png",
|
||||
});
|
||||
expect(resolveCurrentUserIdentity(hello, "missing-browser")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { normalizeSenderIdentity, type SenderIdentity } from "./sender-label.ts";
|
||||
|
||||
type HelloWithPresence = {
|
||||
snapshot?: unknown;
|
||||
};
|
||||
|
||||
/** Finds this browser connection's authenticated user in the Gateway presence snapshot. */
|
||||
export function resolveCurrentUserIdentity(
|
||||
hello: HelloWithPresence | null | undefined,
|
||||
instanceId: string | null | undefined,
|
||||
): SenderIdentity | null {
|
||||
const normalizedInstanceId = instanceId?.trim();
|
||||
const snapshot = hello?.snapshot;
|
||||
if (!normalizedInstanceId || !snapshot || typeof snapshot !== "object") {
|
||||
return null;
|
||||
}
|
||||
const presence = (snapshot as { presence?: unknown }).presence;
|
||||
if (!Array.isArray(presence)) {
|
||||
return null;
|
||||
}
|
||||
const ownPresence = presence.find((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return false;
|
||||
}
|
||||
return (entry as { instanceId?: unknown }).instanceId === normalizedInstanceId;
|
||||
});
|
||||
if (!ownPresence || typeof ownPresence !== "object" || Array.isArray(ownPresence)) {
|
||||
return null;
|
||||
}
|
||||
const user = (ownPresence as { user?: unknown }).user;
|
||||
if (!user || typeof user !== "object" || Array.isArray(user)) {
|
||||
return null;
|
||||
}
|
||||
const record = user as Record<string, unknown>;
|
||||
return normalizeSenderIdentity({
|
||||
id: record.id ?? record.email,
|
||||
name: record.name,
|
||||
profileAvatarUrl: record.avatarUrl,
|
||||
});
|
||||
}
|
||||
@@ -604,13 +604,13 @@ describe("message-normalizer", () => {
|
||||
});
|
||||
|
||||
it("formats durable sender metadata for transcript attribution", () => {
|
||||
expect(
|
||||
normalizeMessage({
|
||||
role: "user",
|
||||
content: "Prompt from Alice",
|
||||
__openclaw: { senderId: "alice@example.com" },
|
||||
}).senderLabel,
|
||||
).toBe("alice");
|
||||
const emailSender = normalizeMessage({
|
||||
role: "user",
|
||||
content: "Prompt from Alice",
|
||||
__openclaw: { senderId: "alice@example.com" },
|
||||
});
|
||||
expect(emailSender.senderLabel).toBe("alice");
|
||||
expect(emailSender.sender).toEqual({ id: "alice@example.com" });
|
||||
expect(
|
||||
normalizeMessage({
|
||||
role: "user",
|
||||
|
||||
@@ -14,7 +14,7 @@ import { splitMediaFromOutput } from "../../../../src/media/parse.js";
|
||||
import { parseInlineDirectives } from "../../../../src/utils/directive-tags.js";
|
||||
import { getMediaFileExtension } from "../media-file-extension.ts";
|
||||
import type { NormalizedMessage, MessageContentItem } from "./chat-types.ts";
|
||||
import { formatSenderLabel } from "./sender-label.ts";
|
||||
import { formatSenderLabel, normalizeSenderIdentity } from "./sender-label.ts";
|
||||
|
||||
export function normalizeRoleForGrouping(role: string): string {
|
||||
const lower = role.toLowerCase();
|
||||
@@ -519,14 +519,16 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
rawOpenClawMeta && typeof rawOpenClawMeta === "object" && !Array.isArray(rawOpenClawMeta)
|
||||
? (rawOpenClawMeta as Record<string, unknown>)
|
||||
: undefined;
|
||||
const sender = normalizeSenderIdentity({
|
||||
id: openClawMeta?.senderId,
|
||||
name: openClawMeta?.senderName,
|
||||
username: openClawMeta?.senderUsername,
|
||||
profileAvatarUrl: openClawMeta?.senderProfileAvatarUrl,
|
||||
});
|
||||
const senderLabel =
|
||||
typeof m.senderLabel === "string" && m.senderLabel.trim()
|
||||
? m.senderLabel.trim()
|
||||
: formatSenderLabel({
|
||||
id: openClawMeta?.senderId,
|
||||
name: openClawMeta?.senderName,
|
||||
username: openClawMeta?.senderUsername,
|
||||
});
|
||||
: formatSenderLabel(sender);
|
||||
|
||||
content = stripMessageDisplayMetadata(content);
|
||||
|
||||
@@ -536,6 +538,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
|
||||
timestamp,
|
||||
id,
|
||||
senderLabel,
|
||||
...(sender ? { sender } : {}),
|
||||
...(audioAsVoice ? { audioAsVoice: true } : {}),
|
||||
...(replyTarget ? { replyTarget } : {}),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
type SenderIdentity = {
|
||||
export type SenderIdentity = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
profileAvatarUrl?: string;
|
||||
};
|
||||
|
||||
type SenderIdentityInput = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
username?: unknown;
|
||||
profileAvatarUrl?: unknown;
|
||||
};
|
||||
|
||||
function normalizeLabelPart(value: unknown): string | null {
|
||||
@@ -20,3 +28,33 @@ export function formatSenderLabel(sender: SenderIdentity | null | undefined): st
|
||||
}
|
||||
return /^([^@\s]+)@[^@\s]+$/.exec(id)?.[1] ?? id;
|
||||
}
|
||||
|
||||
export function normalizeSenderIdentity(
|
||||
sender: SenderIdentityInput | null | undefined,
|
||||
): SenderIdentity | null {
|
||||
const id = normalizeLabelPart(sender?.id);
|
||||
const name = normalizeLabelPart(sender?.name);
|
||||
const username = normalizeLabelPart(sender?.username);
|
||||
const profileAvatarUrl = normalizeLabelPart(sender?.profileAvatarUrl);
|
||||
if (!id && !name && !username && !profileAvatarUrl) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(id ? { id } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(username ? { username } : {}),
|
||||
...(profileAvatarUrl ? { profileAvatarUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function senderIdentityKey(sender: SenderIdentity | null | undefined): string | null {
|
||||
if (!sender) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
sender.id ?? "",
|
||||
sender.name ?? "",
|
||||
sender.username ?? "",
|
||||
sender.profileAvatarUrl ?? "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAvatar } from "./identity-avatar.ts";
|
||||
|
||||
describe("resolveAvatar", () => {
|
||||
it("uses a normalized email id for the proxied avatar hash", async () => {
|
||||
await expect(
|
||||
resolveAvatar({ id: " Alice@Example.com ", avatarProxyBaseUrl: "/api/avatars/" }),
|
||||
).resolves.toEqual({
|
||||
kind: "gravatar",
|
||||
url: "/api/avatars/ff8d9819fc0e12bf0d24892e45987e249a28dce836a85cad60e28eaaa8c6d976?s=64",
|
||||
});
|
||||
});
|
||||
|
||||
it("never contacts a third-party avatar host without a proxy base", async () => {
|
||||
await expect(resolveAvatar({ id: "alice@example.com" })).resolves.toMatchObject({
|
||||
kind: "initials",
|
||||
initials: "A",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to initials for a non-email id", async () => {
|
||||
await expect(resolveAvatar({ id: "profile_123" })).resolves.toMatchObject({
|
||||
kind: "initials",
|
||||
initials: "P",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives up to two initials from a display name", async () => {
|
||||
await expect(resolveAvatar({ name: "Ada Lovelace Byron" })).resolves.toMatchObject({
|
||||
kind: "initials",
|
||||
initials: "AL",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the initials color deterministic", async () => {
|
||||
const first = await resolveAvatar({ id: "profile_123", name: "Ada Lovelace" });
|
||||
const second = await resolveAvatar({ id: "profile_123", name: "Renamed User" });
|
||||
expect(first.kind).toBe("initials");
|
||||
expect(second.kind).toBe("initials");
|
||||
if (first.kind === "initials" && second.kind === "initials") {
|
||||
expect(first.colorSeed).toBe(second.colorSeed);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an already-resolved profile avatar win", async () => {
|
||||
await expect(
|
||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" }),
|
||||
).resolves.toEqual({ kind: "profile", url: "/avatars/alice.png" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAvatar profile URL origin restriction", () => {
|
||||
it("rejects absolute profile URLs from sender metadata", async () => {
|
||||
await expect(
|
||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "https://evil.example/a.png" }),
|
||||
).resolves.toMatchObject({ kind: "initials" });
|
||||
});
|
||||
|
||||
it("rejects protocol-relative profile URLs", async () => {
|
||||
await expect(
|
||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "//evil.example/a.png" }),
|
||||
).resolves.toMatchObject({ kind: "initials" });
|
||||
});
|
||||
|
||||
it("rejects backslash and control-character parser bypasses", async () => {
|
||||
for (const url of [
|
||||
"/\\evil.example/a.png",
|
||||
"\\/evil.example/a.png",
|
||||
"/\t/evil.example/a.png",
|
||||
"htt\nps://evil.example/a.png",
|
||||
]) {
|
||||
await expect(
|
||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: url }),
|
||||
).resolves.toMatchObject({ kind: "initials" });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts same-origin relative profile URLs", async () => {
|
||||
await expect(
|
||||
resolveAvatar({ id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" }),
|
||||
).resolves.toEqual({ kind: "profile", url: "/avatars/alice.png" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { formatSenderLabel, type SenderIdentity } from "./chat/sender-label.ts";
|
||||
|
||||
export type IdentityAvatarInput = SenderIdentity & {
|
||||
profileAvatarUrl?: string;
|
||||
/**
|
||||
* Base URL of a gateway-side avatar proxy (same-origin). When absent, the
|
||||
* email-hash avatar tier is disabled entirely: the browser must never
|
||||
* contact a third-party avatar host directly, because that leaks a
|
||||
* dictionary-recoverable sender email hash plus the viewer's IP per render.
|
||||
*/
|
||||
avatarProxyBaseUrl?: string;
|
||||
};
|
||||
|
||||
export type ResolvedIdentityAvatar =
|
||||
| { kind: "profile"; url: string }
|
||||
| { kind: "gravatar"; url: string }
|
||||
| { kind: "initials"; initials: string; colorSeed: number };
|
||||
|
||||
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+$/;
|
||||
|
||||
function initialsFromLabel(label: string): string {
|
||||
const words = label.trim().split(/\s+/u).filter(Boolean).slice(0, 2);
|
||||
const initials = words.map((word) => Array.from(word)[0] ?? "").join("");
|
||||
return initials.toUpperCase() || "?";
|
||||
}
|
||||
|
||||
const ORIGIN_PROBE = "https://origin-probe.invalid";
|
||||
|
||||
/** True only when the value resolves inside the embedding origin for any base. */
|
||||
function isOriginRelativePath(value: string): boolean {
|
||||
try {
|
||||
return new URL(value, ORIGIN_PROBE).origin === ORIGIN_PROBE;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stableColorSeed(value: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string | null> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(value),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves profile, Gravatar, then deterministic initials without fetching profile data. */
|
||||
export async function resolveAvatar(input: IdentityAvatarInput): Promise<ResolvedIdentityAvatar> {
|
||||
const profileAvatarUrl = input.profileAvatarUrl?.trim();
|
||||
// Same-origin only: profile URLs arrive via sender metadata, and an
|
||||
// absolute URL would let a sender make every viewing browser contact an
|
||||
// arbitrary host. Validate with the URL parser (not string prefixes) so
|
||||
// browser normalization quirks — backslashes as slashes, stripped tab or
|
||||
// newline characters — cannot smuggle in a cross-origin target.
|
||||
if (profileAvatarUrl && isOriginRelativePath(profileAvatarUrl)) {
|
||||
return { kind: "profile", url: profileAvatarUrl };
|
||||
}
|
||||
|
||||
const id = input.id?.trim();
|
||||
const proxyBase = input.avatarProxyBaseUrl?.trim().replace(/\/+$/, "");
|
||||
if (proxyBase && id && EMAIL_PATTERN.test(id)) {
|
||||
const hash = await sha256Hex(id.toLowerCase());
|
||||
if (hash) {
|
||||
return {
|
||||
kind: "gravatar",
|
||||
url: `${proxyBase}/${hash}?s=64`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const label = formatSenderLabel(input) ?? "?";
|
||||
return {
|
||||
kind: "initials",
|
||||
initials: initialsFromLabel(label),
|
||||
colorSeed: stableColorSeed(id || label),
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
replaceSlashCommands,
|
||||
type SlashCommandDef,
|
||||
} from "../../lib/chat/commands.ts";
|
||||
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
|
||||
import {
|
||||
scopedAgentIdForSession,
|
||||
visibleSessionMatches,
|
||||
@@ -316,7 +317,13 @@ export async function dispatchChatSlashCommand(
|
||||
}
|
||||
|
||||
if (result.pendingCurrentRun && host.chatRunId && targetIsCurrent()) {
|
||||
enqueuePendingRunMessage(host, `/${name} ${args}`.trim(), host.chatRunId);
|
||||
enqueuePendingRunMessage(
|
||||
host,
|
||||
`/${name} ${args}`.trim(),
|
||||
host.chatRunId,
|
||||
undefined,
|
||||
resolveCurrentUserIdentity(host.hello, host.client?.instanceId) ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.sessionPatch && "modelOverride" in result.sessionPatch) {
|
||||
|
||||
@@ -547,6 +547,26 @@ describe("renderChatComposer controls", () => {
|
||||
expect(onQueueSteer.mock.calls).toEqual([["queued-1"], ["waiting-idle-1"]]);
|
||||
});
|
||||
|
||||
it("renders the queued author's avatar before the turn is submitted", async () => {
|
||||
const { container } = renderComposer({
|
||||
queue: [
|
||||
{
|
||||
id: "waiting-idle-1",
|
||||
text: "queued during the run",
|
||||
createdAt: 4,
|
||||
sendState: "waiting-idle",
|
||||
sender: { id: "profile_123", name: "Alice Example" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
container.querySelector(".chat-queue__item .chat-author-avatar__initials")?.textContent,
|
||||
).toContain("AE");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders failed sends as retryable and running commands as inert", () => {
|
||||
const onQueueRetry = vi.fn();
|
||||
let view = renderComposer({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Control UI page module owns Chat queue storage and queue item cleanup.
|
||||
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import type { SenderIdentity } from "../../lib/chat/sender-label.ts";
|
||||
import {
|
||||
scopedAgentIdForSession,
|
||||
visibleSessionMatches,
|
||||
@@ -305,6 +306,7 @@ export function enqueueChatMessage(
|
||||
attachments?: ChatAttachment[],
|
||||
refreshSessions?: boolean,
|
||||
localCommand?: { args: string; name: string },
|
||||
sender?: SenderIdentity,
|
||||
): ChatQueueItem | null {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
@@ -321,6 +323,7 @@ export function enqueueChatMessage(
|
||||
localCommandName: localCommand?.name,
|
||||
sessionKey: host.sessionKey,
|
||||
agentId: scopedAgentIdForSession(host, host.sessionKey),
|
||||
...(sender ? { sender } : {}),
|
||||
};
|
||||
host.chatQueue = [...host.chatQueue, item];
|
||||
return item;
|
||||
@@ -331,6 +334,7 @@ export function enqueuePendingRunMessage(
|
||||
text: string,
|
||||
pendingRunId: string,
|
||||
attachments?: ChatAttachment[],
|
||||
sender?: SenderIdentity,
|
||||
) {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
@@ -346,6 +350,7 @@ export function enqueuePendingRunMessage(
|
||||
kind: "steered",
|
||||
attachments: hasAttachments ? cloneChatAttachmentsMetadata(attachments ?? []) : undefined,
|
||||
pendingRunId,
|
||||
...(sender ? { sender } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ChatQueueSkillWorkshopRevision,
|
||||
} from "../../lib/chat/chat-types.ts";
|
||||
import { parseSlashCommand } from "../../lib/chat/commands.ts";
|
||||
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
|
||||
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
|
||||
import { extractSideQuestionDisplayText } from "../../lib/chat/side-question.ts";
|
||||
import {
|
||||
@@ -454,6 +455,7 @@ function enqueuePendingSendMessage(
|
||||
if (!trimmed && !hasAttachments) {
|
||||
return null;
|
||||
}
|
||||
const sender = resolveCurrentUserIdentity(host.hello, host.client?.instanceId);
|
||||
const pending: ChatQueueItem = {
|
||||
id: generateUUID(),
|
||||
text: trimmed,
|
||||
@@ -466,6 +468,7 @@ function enqueuePendingSendMessage(
|
||||
sendSubmittedAtMs: submittedAtMs,
|
||||
sessionKey: host.sessionKey,
|
||||
agentId: scopedAgentIdForSession(host, host.sessionKey),
|
||||
...(sender ? { sender } : {}),
|
||||
...(skillWorkshopRevision ? { skillWorkshopRevision } : {}),
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
};
|
||||
@@ -2266,10 +2269,17 @@ export async function handleSendChat(
|
||||
host.chatMessage = "";
|
||||
resetChatInputHistoryNavigation(host);
|
||||
}
|
||||
const queued = enqueueChatMessage(host, message, undefined, isChatResetCommand(message), {
|
||||
args: parsed.args,
|
||||
name: parsed.command.key,
|
||||
});
|
||||
const queued = enqueueChatMessage(
|
||||
host,
|
||||
message,
|
||||
undefined,
|
||||
isChatResetCommand(message),
|
||||
{
|
||||
args: parsed.args,
|
||||
name: parsed.command.key,
|
||||
},
|
||||
resolveCurrentUserIdentity(host.hello, host.client?.instanceId) ?? undefined,
|
||||
);
|
||||
if (queued) {
|
||||
queued.sendState = reconnectSafeQueuedSendState(host);
|
||||
}
|
||||
|
||||
@@ -2031,11 +2031,16 @@ describe("buildCachedChatItems", () => {
|
||||
createdAt: 2,
|
||||
sendSubmittedAtMs: 10,
|
||||
sendState: "sending",
|
||||
sender: { id: "alice@example.com", name: "Alice Example" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.role)).toEqual(["assistant", "user"]);
|
||||
expect(groupAt(groups, 1).sender).toEqual({
|
||||
id: "alice@example.com",
|
||||
name: "Alice Example",
|
||||
});
|
||||
expect(messageRecord(groupAt(groups, 1)).content).toStrictEqual([
|
||||
{ type: "text", text: "first visible send" },
|
||||
]);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
stripMessageDisplayMetadataText,
|
||||
} from "../../lib/chat/message-normalizer.ts";
|
||||
import { normalizeRoleForGrouping } from "../../lib/chat/message-normalizer.ts";
|
||||
import { senderIdentityKey } from "../../lib/chat/sender-label.ts";
|
||||
import {
|
||||
extractToolCardsCached,
|
||||
extractToolPreview,
|
||||
@@ -398,6 +399,7 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
role.toLowerCase() === "user" || role.toLowerCase() === "assistant"
|
||||
? (normalized.senderLabel ?? null)
|
||||
: null;
|
||||
const sender = role.toLowerCase() === "user" ? normalized.sender : undefined;
|
||||
const timestamp = normalized.timestamp || Date.now();
|
||||
const shouldSplitBySender = role.toLowerCase() === "user" || role.toLowerCase() === "assistant";
|
||||
const startsProjectedTurn =
|
||||
@@ -407,7 +409,9 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
!currentGroup ||
|
||||
startsProjectedTurn ||
|
||||
currentGroup.role !== role ||
|
||||
(shouldSplitBySender && currentGroup.senderLabel !== senderLabel)
|
||||
(shouldSplitBySender &&
|
||||
(currentGroup.senderLabel !== senderLabel ||
|
||||
senderIdentityKey(currentGroup.sender) !== senderIdentityKey(sender)))
|
||||
) {
|
||||
if (currentGroup) {
|
||||
result.push(currentGroup);
|
||||
@@ -417,6 +421,7 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
key: `group:${role}:${item.key}`,
|
||||
role,
|
||||
senderLabel,
|
||||
...(sender ? { sender } : {}),
|
||||
messages: [{ message: item.message, key: item.key, duplicateCount: item.duplicateCount }],
|
||||
timestamp,
|
||||
isStreaming: false,
|
||||
@@ -1093,6 +1098,12 @@ function queuedSendThreadMessage(item: ChatQueueItem): Record<string, unknown> |
|
||||
kind: "pending-send",
|
||||
id: item.id,
|
||||
state: item.sendState,
|
||||
...(item.sender?.id ? { senderId: item.sender.id } : {}),
|
||||
...(item.sender?.name ? { senderName: item.sender.name } : {}),
|
||||
...(item.sender?.username ? { senderUsername: item.sender.username } : {}),
|
||||
...(item.sender?.profileAvatarUrl
|
||||
? { senderProfileAvatarUrl: item.sender.profileAvatarUrl }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1525,6 +1536,7 @@ function sameMessageGroup(previous: MessageGroup, next: MessageGroup): boolean {
|
||||
return (
|
||||
previous.role === next.role &&
|
||||
previous.senderLabel === next.senderLabel &&
|
||||
senderIdentityKey(previous.sender) === senderIdentityKey(next.sender) &&
|
||||
previous.isStreaming === next.isStreaming &&
|
||||
previous.turnSucceeded === next.turnSucceeded &&
|
||||
previous.messages.length === next.messages.length &&
|
||||
@@ -1625,7 +1637,8 @@ function stabilizeChatItems(
|
||||
!prior ||
|
||||
claimedGroupKeys.has(prior.key) ||
|
||||
prior.role !== item.role ||
|
||||
prior.senderLabel !== item.senderLabel
|
||||
prior.senderLabel !== item.senderLabel ||
|
||||
senderIdentityKey(prior.sender) !== senderIdentityKey(item.sender)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { until } from "lit/directives/until.js";
|
||||
import { formatSenderLabel } from "../../../lib/chat/sender-label.ts";
|
||||
import {
|
||||
resolveAvatar,
|
||||
type IdentityAvatarInput,
|
||||
type ResolvedIdentityAvatar,
|
||||
} from "../../../lib/identity-avatar.ts";
|
||||
|
||||
function renderInitialsAvatar(
|
||||
avatar: Extract<ResolvedIdentityAvatar, { kind: "initials" }>,
|
||||
fallback = false,
|
||||
) {
|
||||
const hue = avatar.colorSeed % 360;
|
||||
return html`
|
||||
<span
|
||||
class="chat-author-avatar__initials ${fallback ? "chat-author-avatar__fallback" : ""}"
|
||||
style=${`--chat-author-avatar-hue: ${hue}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
${avatar.initials}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderResolvedAvatar(
|
||||
avatar: ResolvedIdentityAvatar,
|
||||
fallback: Extract<ResolvedIdentityAvatar, { kind: "initials" }>,
|
||||
): TemplateResult {
|
||||
if (avatar.kind === "initials") {
|
||||
return renderInitialsAvatar(avatar);
|
||||
}
|
||||
return html`
|
||||
<img
|
||||
class="chat-author-avatar__image"
|
||||
src=${avatar.url}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
@error=${(event: Event) => {
|
||||
const image = event.currentTarget;
|
||||
if (image instanceof HTMLImageElement) {
|
||||
image.closest<HTMLElement>(".chat-author-avatar")?.classList.add("is-fallback");
|
||||
}
|
||||
}}
|
||||
@load=${(event: Event) => {
|
||||
// Lit reuses DOM parts across renders; a prior sender's error state
|
||||
// must not hide a successfully loaded avatar for the next source.
|
||||
const image = event.currentTarget;
|
||||
if (image instanceof HTMLImageElement) {
|
||||
image.closest<HTMLElement>(".chat-author-avatar")?.classList.remove("is-fallback");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
${renderInitialsAvatar(fallback, true)}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Small author marker shared by transcript bubbles and the pending-send queue. */
|
||||
export function renderChatAuthorAvatar(
|
||||
sender: IdentityAvatarInput | null | undefined,
|
||||
): TemplateResult | typeof nothing {
|
||||
const label = formatSenderLabel(sender);
|
||||
if (!sender || !label) {
|
||||
return nothing;
|
||||
}
|
||||
const resolved = Promise.all([resolveAvatar(sender), resolveAvatar({ name: label })]).then(
|
||||
([avatar, fallback]) => {
|
||||
const initials =
|
||||
fallback.kind === "initials"
|
||||
? fallback
|
||||
: ({ kind: "initials", initials: "?", colorSeed: 0 } as const);
|
||||
return renderResolvedAvatar(avatar, initials);
|
||||
},
|
||||
);
|
||||
return html`
|
||||
<span class="chat-author-avatar" role="img" aria-label=${label} title=${label}>
|
||||
${until(resolved, nothing)}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
renderChatAttachmentInputs,
|
||||
renderChatAttachmentMenu,
|
||||
} from "./chat-attachments.ts";
|
||||
import { renderChatAuthorAvatar } from "./chat-author-avatar.ts";
|
||||
import { renderChatPlanChecklist } from "./chat-plan-checklist.ts";
|
||||
import { createGatewayQuestionPanelProps } from "./chat-question-card.ts";
|
||||
import {
|
||||
@@ -1099,6 +1100,7 @@ function renderChatQueueItem(item: ChatQueueItem, props: ChatQueueProps) {
|
||||
<span class="chat-queue__icon" aria-hidden="true">
|
||||
${failed ? icons.alertTriangle : icons.clock}
|
||||
</span>
|
||||
${renderChatAuthorAvatar(item.sender)}
|
||||
${steered
|
||||
? html`<span class="chat-queue__badge chat-queue__badge--steered"
|
||||
>${t("chat.queue.steered")}</span
|
||||
|
||||
@@ -1258,6 +1258,118 @@ describe("grouped chat rendering", () => {
|
||||
).toBe("alice");
|
||||
});
|
||||
|
||||
it("renders an author avatar for a user group with sender identity", async () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderMessageGroup(
|
||||
{
|
||||
kind: "group",
|
||||
key: "attributed-user",
|
||||
role: "user",
|
||||
senderLabel: "Alice Example",
|
||||
sender: { id: "profile_123", name: "Alice Example" },
|
||||
messages: [
|
||||
{
|
||||
key: "attributed-message",
|
||||
message: { role: "user", content: "hello", timestamp: 1000 },
|
||||
},
|
||||
],
|
||||
timestamp: 1000,
|
||||
isStreaming: false,
|
||||
},
|
||||
{
|
||||
showReasoning: true,
|
||||
showToolCalls: true,
|
||||
assistantName: "OpenClaw",
|
||||
},
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector(".chat-author-avatar__initials")?.textContent?.trim()).toBe(
|
||||
"AE",
|
||||
);
|
||||
});
|
||||
expect(container.querySelector(".chat-author-avatar")?.getAttribute("title")).toBe(
|
||||
"Alice Example",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to initials when a user avatar image fails", async () => {
|
||||
const container = document.createElement("div");
|
||||
const group: MessageGroup = {
|
||||
kind: "group",
|
||||
key: "gravatar-user",
|
||||
role: "user",
|
||||
senderLabel: "alice",
|
||||
// profileAvatarUrl exercises the img tier; bare emails render initials
|
||||
// only (no third-party avatar fetch without a gateway proxy base).
|
||||
sender: { id: "alice@example.com", profileAvatarUrl: "/avatars/alice.png" },
|
||||
messages: [
|
||||
{
|
||||
key: "gravatar-message",
|
||||
message: { role: "user", content: "hello", timestamp: 1000 },
|
||||
},
|
||||
],
|
||||
timestamp: 1000,
|
||||
isStreaming: false,
|
||||
};
|
||||
render(
|
||||
renderMessageGroup(group, {
|
||||
showReasoning: true,
|
||||
showToolCalls: true,
|
||||
assistantName: "OpenClaw",
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const image = await vi.waitFor(() => {
|
||||
const result = container.querySelector<HTMLImageElement>(".chat-author-avatar__image");
|
||||
expect(result).not.toBeNull();
|
||||
return result!;
|
||||
});
|
||||
image.dispatchEvent(new Event("error"));
|
||||
expect(container.querySelector(".chat-author-avatar")?.classList.contains("is-fallback")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(container.querySelector(".chat-author-avatar__fallback")?.textContent?.trim()).toBe("A");
|
||||
});
|
||||
|
||||
it("does not render an author avatar for a user group without sender identity", () => {
|
||||
const container = document.createElement("div");
|
||||
renderGroupedMessage(container, { role: "user", content: "hello", timestamp: 1000 }, "user");
|
||||
expect(container.querySelector(".chat-author-avatar")).toBeNull();
|
||||
});
|
||||
|
||||
it("never renders a user author avatar on assistant output", () => {
|
||||
const container = document.createElement("div");
|
||||
const group: MessageGroup = {
|
||||
kind: "group",
|
||||
key: "assistant-with-sender",
|
||||
role: "assistant",
|
||||
senderLabel: "Forwarded Agent",
|
||||
sender: { id: "agent@example.com", name: "Forwarded Agent" },
|
||||
messages: [
|
||||
{
|
||||
key: "assistant-message",
|
||||
message: { role: "assistant", content: "hello", timestamp: 1000 },
|
||||
},
|
||||
],
|
||||
timestamp: 1000,
|
||||
isStreaming: false,
|
||||
};
|
||||
render(
|
||||
renderMessageGroup(group, {
|
||||
showReasoning: true,
|
||||
showToolCalls: true,
|
||||
assistantName: "OpenClaw",
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(container.querySelector(".chat-author-avatar")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses assistant senderLabel for forwarded assistant-side groups", () => {
|
||||
const container = document.createElement("div");
|
||||
const group: MessageGroup = {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { getSafeLocalStorage } from "../../../local-storage.ts";
|
||||
import { renderChatAvatar } from "../chat-avatar.ts";
|
||||
import { persistedMessageEntryId } from "../chat-thread.ts";
|
||||
import type { PlanStatus } from "../tool-stream.ts";
|
||||
import { renderChatAuthorAvatar } from "./chat-author-avatar.ts";
|
||||
import { renderChatPlanChecklist } from "./chat-plan-checklist.ts";
|
||||
import { renderChatQuestionSummary } from "./chat-question-card.ts";
|
||||
import type { SidebarContent } from "./chat-sidebar.ts";
|
||||
@@ -1005,6 +1006,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
|
||||
${opts.onDelete && normalizedRole === "user"
|
||||
? renderDeleteButton(opts.onDelete, "left")
|
||||
: nothing}
|
||||
${normalizedRole === "user" ? renderChatAuthorAvatar(group.sender) : nothing}
|
||||
<span class="chat-sender-name">${who}</span>
|
||||
${renderMessageMeta(group.timestamp, meta)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
ChatQueueItem,
|
||||
ChatQueueSkillWorkshopRevision,
|
||||
} from "../../lib/chat/chat-types.ts";
|
||||
import { normalizeSenderIdentity } from "../../lib/chat/sender-label.ts";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_MAIN_KEY,
|
||||
@@ -774,6 +775,7 @@ function serializeQueueItem(item: ChatQueueItem): ChatQueueItem | null {
|
||||
const sendError =
|
||||
item.sendState === "waiting-model" ? INTERRUPTED_SETTINGS_WAIT_ERROR : item.sendError;
|
||||
const skillWorkshopRevision = normalizeSkillWorkshopRevision(item.skillWorkshopRevision);
|
||||
const sender = normalizeSenderIdentity(item.sender);
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
@@ -789,6 +791,7 @@ function serializeQueueItem(item: ChatQueueItem): ChatQueueItem | null {
|
||||
...(item.localCommandName ? { localCommandName: item.localCommandName } : {}),
|
||||
...(item.sessionKey ? { sessionKey: item.sessionKey } : {}),
|
||||
...(item.agentId ? { agentId: item.agentId } : {}),
|
||||
...(sender ? { sender } : {}),
|
||||
...(skillWorkshopRevision ? { skillWorkshopRevision } : {}),
|
||||
...(sendState ? { sendState } : {}),
|
||||
...(sendError ? { sendError } : {}),
|
||||
@@ -819,6 +822,10 @@ function normalizeQueueItem(value: unknown): ChatQueueItem | null {
|
||||
.filter((item): item is ChatAttachment => item !== null)
|
||||
: [];
|
||||
const item: ChatQueueItem = { id, text, createdAt };
|
||||
const sender = normalizeSenderIdentity(entry.sender as Record<string, unknown> | undefined);
|
||||
if (sender) {
|
||||
item.sender = sender;
|
||||
}
|
||||
if (entry.kind === "queued" || entry.kind === "steered") {
|
||||
item.kind = entry.kind;
|
||||
}
|
||||
|
||||
@@ -123,6 +123,56 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-author-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 68%, transparent);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chat-author-avatar__image,
|
||||
.chat-author-avatar__initials {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.chat-author-avatar__image {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.chat-author-avatar__initials {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: hsl(var(--chat-author-avatar-hue) 32% 42%);
|
||||
color: white;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.chat-author-avatar__fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-author-avatar.is-fallback .chat-author-avatar__image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-author-avatar.is-fallback .chat-author-avatar__fallback {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.chat-group-timestamp {
|
||||
font-size: 12px; /* was 11px */
|
||||
color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user