mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 23:55:12 +00:00
fix(ui): polish assistant identity settings
Polishes the basic config identity layout, aligns assistant avatar rendering with chat, and adds a Control UI assistant avatar override with IDENTITY.md fallback.
This commit is contained in:
@@ -723,17 +723,26 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String?
|
||||
public let avatar: String?
|
||||
public let avatarsource: String?
|
||||
public let avatarstatus: String?
|
||||
public let avatarreason: String?
|
||||
public let emoji: String?
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String?,
|
||||
avatar: String?,
|
||||
avatarsource: String?,
|
||||
avatarstatus: String?,
|
||||
avatarreason: String?,
|
||||
emoji: String?)
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.avatar = avatar
|
||||
self.avatarsource = avatarsource
|
||||
self.avatarstatus = avatarstatus
|
||||
self.avatarreason = avatarreason
|
||||
self.emoji = emoji
|
||||
}
|
||||
|
||||
@@ -741,6 +750,9 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case avatar
|
||||
case avatarsource = "avatarSource"
|
||||
case avatarstatus = "avatarStatus"
|
||||
case avatarreason = "avatarReason"
|
||||
case emoji
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,17 +723,26 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String?
|
||||
public let avatar: String?
|
||||
public let avatarsource: String?
|
||||
public let avatarstatus: String?
|
||||
public let avatarreason: String?
|
||||
public let emoji: String?
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String?,
|
||||
avatar: String?,
|
||||
avatarsource: String?,
|
||||
avatarstatus: String?,
|
||||
avatarreason: String?,
|
||||
emoji: String?)
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.avatar = avatar
|
||||
self.avatarsource = avatarsource
|
||||
self.avatarstatus = avatarstatus
|
||||
self.avatarreason = avatarreason
|
||||
self.emoji = emoji
|
||||
}
|
||||
|
||||
@@ -741,6 +750,9 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case avatar
|
||||
case avatarsource = "avatarSource"
|
||||
case avatarstatus = "avatarStatus"
|
||||
case avatarreason = "avatarReason"
|
||||
case emoji
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
||||
import { resolveAgentAvatar } from "./identity-avatar.js";
|
||||
import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js";
|
||||
|
||||
async function writeFile(filePath: string, contents = "avatar") {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
@@ -138,9 +138,55 @@ describe("resolveAgentAvatar", () => {
|
||||
expect(resolved.kind).toBe("none");
|
||||
if (resolved.kind === "none") {
|
||||
expect(resolved.reason).toBe("missing");
|
||||
expect(resolved.source).toBe("avatars/missing.png");
|
||||
expect(resolvePublicAgentAvatarSource(resolved)).toBe("avatars/missing.png");
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts unsafe public avatar sources", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
const outsidePath = path.join(root, "outside.png");
|
||||
await writeFile(outsidePath);
|
||||
|
||||
const absolute = resolveAgentAvatar(
|
||||
{
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: outsidePath } }],
|
||||
},
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(absolute.kind).toBe("none");
|
||||
expect(resolvePublicAgentAvatarSource(absolute)).toBeUndefined();
|
||||
|
||||
expect(
|
||||
resolvePublicAgentAvatarSource({
|
||||
kind: "remote",
|
||||
source: "https://example.com/avatar.png?token=secret",
|
||||
}),
|
||||
).toBe("remote URL");
|
||||
expect(
|
||||
resolvePublicAgentAvatarSource({
|
||||
kind: "data",
|
||||
source: "data:image/png;base64,aaaaaaaa",
|
||||
}),
|
||||
).toBe("data:image/png;base64,...");
|
||||
expect(
|
||||
resolvePublicAgentAvatarSource({
|
||||
kind: "none",
|
||||
source: "../secret.png",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolvePublicAgentAvatarSource({
|
||||
kind: "none",
|
||||
source: "file:///Users/test/private/avatar.png",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects local avatars larger than max bytes", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
@@ -173,9 +219,15 @@ describe("resolveAgentAvatar", () => {
|
||||
|
||||
const remote = resolveAgentAvatar(cfg, "main");
|
||||
expect(remote.kind).toBe("remote");
|
||||
if (remote.kind === "remote") {
|
||||
expect(remote.source).toBe("https://example.com/avatar.png");
|
||||
}
|
||||
|
||||
const data = resolveAgentAvatar(cfg, "data");
|
||||
expect(data.kind).toBe("data");
|
||||
if (data.kind === "data") {
|
||||
expect(data.source).toBe("data:image/png;base64,aaaa");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves local avatar from ui.assistant.avatar when no agents.list identity is set", async () => {
|
||||
|
||||
@@ -3,8 +3,10 @@ import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
AVATAR_MAX_BYTES,
|
||||
hasAvatarUriScheme,
|
||||
isAvatarDataUrl,
|
||||
isAvatarHttpUrl,
|
||||
isWindowsAbsolutePath,
|
||||
isPathWithinRoot,
|
||||
isSupportedLocalAvatarExtension,
|
||||
} from "../shared/avatar-policy.js";
|
||||
@@ -15,10 +17,18 @@ import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
|
||||
import { resolveAgentIdentity } from "./identity.js";
|
||||
|
||||
export type AgentAvatarResolution =
|
||||
| { kind: "none"; reason: string }
|
||||
| { kind: "local"; filePath: string }
|
||||
| { kind: "remote"; url: string }
|
||||
| { kind: "data"; url: string };
|
||||
| { kind: "none"; reason: string; source?: string }
|
||||
| { kind: "local"; filePath: string; source: string }
|
||||
| { kind: "remote"; url: string; source: string }
|
||||
| { kind: "data"; url: string; source: string };
|
||||
|
||||
type AgentAvatarPublicSourceInput = {
|
||||
kind: AgentAvatarResolution["kind"];
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256;
|
||||
const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64;
|
||||
|
||||
function resolveAvatarSource(
|
||||
cfg: OpenClawConfig,
|
||||
@@ -80,6 +90,42 @@ function resolveLocalAvatarPath(params: {
|
||||
return { ok: true, filePath: realPath };
|
||||
}
|
||||
|
||||
function isSafeRelativeAvatarSource(source: string): boolean {
|
||||
if (
|
||||
source.length > PUBLIC_AVATAR_SOURCE_MAX_CHARS ||
|
||||
source.startsWith("~") ||
|
||||
path.isAbsolute(source) ||
|
||||
isWindowsAbsolutePath(source) ||
|
||||
(hasAvatarUriScheme(source) && !isWindowsAbsolutePath(source)) ||
|
||||
source.includes("\0")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const parts = source.replace(/\\/g, "/").split("/");
|
||||
return parts.every((part) => part !== "..");
|
||||
}
|
||||
|
||||
export function resolvePublicAgentAvatarSource(
|
||||
resolved: AgentAvatarPublicSourceInput,
|
||||
): string | undefined {
|
||||
const source = normalizeOptionalString(resolved.source) ?? null;
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAvatarDataUrl(source)) {
|
||||
const commaIndex = source.indexOf(",");
|
||||
const header =
|
||||
commaIndex > 0
|
||||
? source.slice(0, Math.min(commaIndex, PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS))
|
||||
: source.slice(0, PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS);
|
||||
return `${header},...`;
|
||||
}
|
||||
if (isAvatarHttpUrl(source)) {
|
||||
return "remote URL";
|
||||
}
|
||||
return isSafeRelativeAvatarSource(source) ? source : undefined;
|
||||
}
|
||||
|
||||
export function resolveAgentAvatar(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
@@ -90,15 +136,15 @@ export function resolveAgentAvatar(
|
||||
return { kind: "none", reason: "missing" };
|
||||
}
|
||||
if (isAvatarHttpUrl(source)) {
|
||||
return { kind: "remote", url: source };
|
||||
return { kind: "remote", url: source, source };
|
||||
}
|
||||
if (isAvatarDataUrl(source)) {
|
||||
return { kind: "data", url: source };
|
||||
return { kind: "data", url: source, source };
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir });
|
||||
if (!resolved.ok) {
|
||||
return { kind: "none", reason: resolved.reason };
|
||||
return { kind: "none", reason: resolved.reason, source };
|
||||
}
|
||||
return { kind: "local", filePath: resolved.filePath };
|
||||
return { kind: "local", filePath: resolved.filePath, source };
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ async function withSafeBinsExecTool(
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_SHELL_ENV_TIMEOUT_MS: "1",
|
||||
PATH: "/usr/bin:/bin",
|
||||
SHELL: "/bin/sh",
|
||||
},
|
||||
async () => {
|
||||
|
||||
25
src/auto-reply/heartbeat-filter.browser-import.test.ts
Normal file
25
src/auto-reply/heartbeat-filter.browser-import.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { build } from "esbuild";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("heartbeat-filter browser import", () => {
|
||||
it("does not pull node-only utils into browser bundles", async () => {
|
||||
const bundled = await build({
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
stdin: {
|
||||
contents: [
|
||||
'import { isHeartbeatOkResponse } from "./src/auto-reply/heartbeat-filter.ts";',
|
||||
"globalThis.__heartbeatOk = isHeartbeatOkResponse;",
|
||||
].join("\n"),
|
||||
loader: "ts",
|
||||
resolveDir: process.cwd(),
|
||||
sourcefile: "heartbeat-filter-browser-entry.ts",
|
||||
},
|
||||
write: false,
|
||||
});
|
||||
|
||||
expect(Object.keys(bundled.metafile.inputs)).not.toContain("src/utils.ts");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { escapeRegExp } from "../shared/regexp.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { escapeRegExp } from "../utils.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
export type HeartbeatTask = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeRegExp } from "../utils.js";
|
||||
import { escapeRegExp } from "../shared/regexp.js";
|
||||
|
||||
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
|
||||
export const SILENT_REPLY_TOKEN = "NO_REPLY";
|
||||
|
||||
@@ -6,6 +6,9 @@ export type ControlUiBootstrapConfig = {
|
||||
basePath: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
assistantAgentId: string;
|
||||
serverVersion?: string;
|
||||
localMediaPreviewRoots?: string[];
|
||||
|
||||
@@ -98,7 +98,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
async function runAvatarRequest(params: {
|
||||
url: string;
|
||||
method: "GET" | "HEAD";
|
||||
method: "GET" | "HEAD" | "POST";
|
||||
resolveAvatar: Parameters<typeof handleControlUiAvatarRequest>[2]["resolveAvatar"];
|
||||
basePath?: string;
|
||||
auth?: ResolvedGatewayAuth;
|
||||
@@ -791,13 +791,41 @@ describe("handleControlUiHttpRequest", () => {
|
||||
headers: {
|
||||
authorization: "Bearer test-token",
|
||||
},
|
||||
resolveAvatar: () => ({ kind: "remote", url: "https://example.com/avatar.png" }),
|
||||
resolveAvatar: () => ({
|
||||
kind: "remote",
|
||||
url: "https://example.com/avatar.png",
|
||||
source: "https://example.com/avatar.png",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
avatarSource: "remote URL",
|
||||
avatarStatus: "remote",
|
||||
avatarReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts unsafe avatar source values from metadata", async () => {
|
||||
const { res, end, handled } = await runAvatarRequest({
|
||||
url: "/avatar/main?meta=1",
|
||||
method: "GET",
|
||||
resolveAvatar: () => ({
|
||||
kind: "none",
|
||||
reason: "outside_workspace",
|
||||
source: "/Users/test/private/avatar.png",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({
|
||||
avatarUrl: null,
|
||||
avatarSource: null,
|
||||
avatarStatus: "none",
|
||||
avatarReason: "outside_workspace",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import path from "node:path";
|
||||
import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "../agents/identity-avatar.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import {
|
||||
@@ -134,15 +135,33 @@ const STATIC_ASSET_EXTENSIONS = new Set([
|
||||
]);
|
||||
|
||||
export type ControlUiAvatarResolution =
|
||||
| { kind: "none"; reason: string }
|
||||
| { kind: "local"; filePath: string }
|
||||
| { kind: "remote"; url: string }
|
||||
| { kind: "data"; url: string };
|
||||
| { kind: "none"; reason: string; source?: string | null }
|
||||
| { kind: "local"; filePath: string; source?: string | null }
|
||||
| { kind: "remote"; url: string; source?: string | null }
|
||||
| { kind: "data"; url: string; source?: string | null };
|
||||
|
||||
type ControlUiAvatarMeta = {
|
||||
avatarUrl: string | null;
|
||||
avatarSource: string | null;
|
||||
avatarStatus: ControlUiAvatarResolution["kind"];
|
||||
avatarReason: string | null;
|
||||
};
|
||||
|
||||
function controlUiAvatarResolutionMeta(resolved: ControlUiAvatarResolution | null): {
|
||||
avatarSource: string | null;
|
||||
avatarStatus: ControlUiAvatarResolution["kind"] | null;
|
||||
avatarReason: string | null;
|
||||
} {
|
||||
if (!resolved) {
|
||||
return { avatarSource: null, avatarStatus: null, avatarReason: null };
|
||||
}
|
||||
return {
|
||||
avatarSource: resolvePublicAgentAvatarSource(resolved) ?? null,
|
||||
avatarStatus: resolved.kind,
|
||||
avatarReason: resolved.kind === "none" ? resolved.reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
function applyControlUiSecurityHeaders(res: ServerResponse) {
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Content-Security-Policy", buildControlUiCspHeader());
|
||||
@@ -251,6 +270,7 @@ async function authorizeControlUiReadRequest(
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
allowQueryToken?: boolean;
|
||||
requiredOperatorMethod?: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
if (!opts?.auth) {
|
||||
@@ -313,7 +333,10 @@ async function authorizeControlUiReadRequest(
|
||||
const requestedScopes = resolveTrustedHttpOperatorScopes(req, {
|
||||
trustDeclaredOperatorScopes,
|
||||
});
|
||||
const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes);
|
||||
const scopeAuth = authorizeOperatorScopesForMethod(
|
||||
opts.requiredOperatorMethod ?? "assistant.media.get",
|
||||
requestedScopes,
|
||||
);
|
||||
if (!scopeAuth.allowed) {
|
||||
sendJson(res, 403, {
|
||||
ok: false,
|
||||
@@ -550,6 +573,13 @@ export async function handleControlUiAvatarRequest(
|
||||
}
|
||||
|
||||
applyControlUiSecurityHeaders(res);
|
||||
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
|
||||
const agentId = agentIdParts[0] ?? "";
|
||||
if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) {
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await authorizeControlUiReadRequest(req, res, {
|
||||
auth: opts.auth,
|
||||
@@ -561,22 +591,21 @@ export async function handleControlUiAvatarRequest(
|
||||
return true;
|
||||
}
|
||||
|
||||
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
|
||||
const agentId = agentIdParts[0] ?? "";
|
||||
if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) {
|
||||
respondControlUiNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.searchParams.get("meta") === "1") {
|
||||
const resolved = opts.resolveAvatar(agentId);
|
||||
const meta = controlUiAvatarResolutionMeta(resolved);
|
||||
const avatarUrl =
|
||||
resolved.kind === "local"
|
||||
? buildControlUiAvatarUrl(basePath, agentId)
|
||||
: resolved.kind === "remote" || resolved.kind === "data"
|
||||
? resolved.url
|
||||
: null;
|
||||
sendJson(res, 200, { avatarUrl } satisfies ControlUiAvatarMeta);
|
||||
sendJson(res, 200, {
|
||||
avatarUrl,
|
||||
avatarSource: meta.avatarSource,
|
||||
avatarStatus: meta.avatarStatus ?? resolved.kind,
|
||||
avatarReason: meta.avatarReason,
|
||||
} satisfies ControlUiAvatarMeta);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -747,6 +776,11 @@ export async function handleControlUiHttpRequest(
|
||||
agentId: identity.agentId,
|
||||
basePath,
|
||||
});
|
||||
const avatarMeta = config
|
||||
? controlUiAvatarResolutionMeta(
|
||||
resolveAgentAvatar(config, identity.agentId, { includeUiOverride: true }),
|
||||
)
|
||||
: controlUiAvatarResolutionMeta(null);
|
||||
if (req.method === "HEAD") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
@@ -758,6 +792,9 @@ export async function handleControlUiHttpRequest(
|
||||
basePath,
|
||||
assistantName: identity.name,
|
||||
assistantAvatar: avatarValue ?? identity.avatar,
|
||||
assistantAvatarSource: avatarMeta.avatarSource,
|
||||
assistantAvatarStatus: avatarMeta.avatarStatus,
|
||||
assistantAvatarReason: avatarMeta.avatarReason,
|
||||
assistantAgentId: identity.agentId,
|
||||
serverVersion: resolveRuntimeServiceVersion(process.env),
|
||||
localMediaPreviewRoots: [...getAgentScopedMediaLocalRoots(config ?? {}, identity.agentId)],
|
||||
|
||||
@@ -181,6 +181,9 @@ export const AgentIdentityResultSchema = Type.Object(
|
||||
agentId: NonEmptyString,
|
||||
name: Type.Optional(NonEmptyString),
|
||||
avatar: Type.Optional(NonEmptyString),
|
||||
avatarSource: Type.Optional(NonEmptyString),
|
||||
avatarStatus: Type.Optional(Type.String({ enum: ["none", "local", "remote", "data"] })),
|
||||
avatarReason: Type.Optional(NonEmptyString),
|
||||
emoji: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
|
||||
@@ -75,6 +75,8 @@ vi.mock("../../config/config.js", async () => {
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
listAgentIds: mocks.listAgentIds,
|
||||
resolveDefaultAgentId: () => "main",
|
||||
resolveAgentConfig: (cfg: { agents?: { list?: Array<{ id?: string }> } }, agentId: string) =>
|
||||
cfg.agents?.list?.find((agent) => agent.id === agentId),
|
||||
resolveAgentWorkspaceDir: (cfg: { agents?: { defaults?: { workspace?: string } } }) =>
|
||||
cfg?.agents?.defaults?.workspace ?? "/tmp/workspace",
|
||||
resolveAgentEffectiveModelPrimary: () => undefined,
|
||||
@@ -347,6 +349,7 @@ describe("gateway agent handler", () => {
|
||||
}
|
||||
resetDetachedTaskLifecycleRuntimeForTests();
|
||||
resetTaskRegistryForTests();
|
||||
mocks.loadConfigReturn = {};
|
||||
mocks.resolveExplicitAgentSessionKey.mockReset().mockReturnValue(undefined);
|
||||
mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true);
|
||||
mocks.listAgentIds.mockReset().mockReturnValue(["main"]);
|
||||
@@ -1553,6 +1556,33 @@ describe("gateway agent handler", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts unsafe avatar sources in agent.identity.get", async () => {
|
||||
mocks.loadConfigReturn = {
|
||||
agents: {
|
||||
defaults: { workspace: "/tmp/workspace" },
|
||||
list: [{ id: "main", identity: { avatar: "/Users/test/private/avatar.png" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const respond = await invokeAgentIdentityGet(
|
||||
{
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
{ reqId: "5-avatar-source" },
|
||||
);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
avatarSource: undefined,
|
||||
avatarStatus: "none",
|
||||
avatarReason: "outside_workspace",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway agent handler chat.abort integration", () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveAgentAvatar,
|
||||
resolvePublicAgentAvatarSource,
|
||||
} from "../../agents/identity-avatar.js";
|
||||
import type { AgentInternalEvent } from "../../agents/internal-events.js";
|
||||
import {
|
||||
normalizeSpawnedRunMetadata,
|
||||
@@ -1074,7 +1078,18 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
agentId: identity.agentId,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
}) ?? identity.avatar;
|
||||
respond(true, { ...identity, avatar: avatarValue }, undefined);
|
||||
const avatarResolution = resolveAgentAvatar(cfg, identity.agentId, { includeUiOverride: true });
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
...identity,
|
||||
avatar: avatarValue,
|
||||
avatarSource: resolvePublicAgentAvatarSource(avatarResolution),
|
||||
avatarStatus: avatarResolution.kind,
|
||||
avatarReason: avatarResolution.kind === "none" ? avatarResolution.reason : undefined,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"agent.wait": async ({ params, respond, context }) => {
|
||||
if (!validateAgentWaitParams(params)) {
|
||||
|
||||
@@ -607,29 +607,43 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
|
||||
);
|
||||
|
||||
it("handles transparent and semantic env wrappers in allowlist mode", async () => {
|
||||
const transparent = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
security: "allowlist",
|
||||
command: ["env", "tr", "a", "b"],
|
||||
});
|
||||
if (process.platform === "win32") {
|
||||
expect(transparent.runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(transparent.sendInvokeResult, { message: "allowlist miss" });
|
||||
} else {
|
||||
const runArgs = vi.mocked(transparent.runCommand).mock.calls[0]?.[0] as string[] | undefined;
|
||||
expect(runArgs).toBeDefined();
|
||||
expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/);
|
||||
expect(runArgs?.slice(1)).toEqual(["a", "b"]);
|
||||
expectInvokeOk(transparent.sendInvokeResult);
|
||||
const oldPath = process.env.PATH;
|
||||
if (process.platform !== "win32") {
|
||||
process.env.PATH = "/usr/bin:/bin";
|
||||
}
|
||||
try {
|
||||
const transparent = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
security: "allowlist",
|
||||
command: ["env", "tr", "a", "b"],
|
||||
});
|
||||
if (process.platform === "win32") {
|
||||
expect(transparent.runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(transparent.sendInvokeResult, { message: "allowlist miss" });
|
||||
} else {
|
||||
const runArgs = vi.mocked(transparent.runCommand).mock.calls[0]?.[0] as
|
||||
| string[]
|
||||
| undefined;
|
||||
expect(runArgs).toBeDefined();
|
||||
expect(runArgs?.[0]).toMatch(/(^|[/\\])tr$/);
|
||||
expect(runArgs?.slice(1)).toEqual(["a", "b"]);
|
||||
expectInvokeOk(transparent.sendInvokeResult);
|
||||
}
|
||||
|
||||
const semantic = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
security: "allowlist",
|
||||
command: ["env", "FOO=bar", "tr", "a", "b"],
|
||||
});
|
||||
expect(semantic.runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(semantic.sendInvokeResult, { message: "allowlist miss" });
|
||||
const semantic = await runSystemInvoke({
|
||||
preferMacAppExecHost: false,
|
||||
security: "allowlist",
|
||||
command: ["env", "FOO=bar", "tr", "a", "b"],
|
||||
});
|
||||
expect(semantic.runCommand).not.toHaveBeenCalled();
|
||||
expectInvokeErrorMessage(semantic.sendInvokeResult, { message: "allowlist miss" });
|
||||
} finally {
|
||||
if (oldPath === undefined) {
|
||||
delete process.env.PATH;
|
||||
} else {
|
||||
process.env.PATH = oldPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("denies shell payload carriers in allowlist mode without explicit approval", async () => {
|
||||
|
||||
3
src/shared/regexp.ts
Normal file
3
src/shared/regexp.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveRequiredHomeDir,
|
||||
} from "./infra/home-dir.js";
|
||||
import { isPlainObject } from "./infra/plain-object.js";
|
||||
export { escapeRegExp } from "./shared/regexp.js";
|
||||
|
||||
export async function ensureDir(dir: string) {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
@@ -35,13 +36,6 @@ export function clampInt(value: number, min: number, max: number): number {
|
||||
/** Alias for clampNumber (shorter, more common name) */
|
||||
export const clamp = clampNumber;
|
||||
|
||||
/**
|
||||
* Escapes special regex characters in a string so it can be used in a RegExp constructor.
|
||||
*/
|
||||
export function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON, returning null on error instead of throwing.
|
||||
*/
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
.qs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 14px;
|
||||
}
|
||||
@@ -56,6 +56,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qs-stack--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
|
||||
.qs-card {
|
||||
@@ -78,6 +82,10 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.qs-card--personal {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.qs-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -180,6 +188,12 @@
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.qs-row__value--action:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border) 45%);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.qs-row__value--action code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
@@ -208,35 +222,144 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-personal-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.qs-identity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||
gap: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
|
||||
.qs-personal-preview__copy {
|
||||
.qs-identity-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-content: start;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 18%,
|
||||
color-mix(in srgb, var(--accent) 10%, transparent),
|
||||
transparent 46%
|
||||
),
|
||||
color-mix(in srgb, var(--bg-elevated) 42%, var(--card) 58%);
|
||||
}
|
||||
|
||||
.qs-identity-card--assistant {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 82% 12%,
|
||||
color-mix(in srgb, var(--accent) 14%, transparent),
|
||||
transparent 48%
|
||||
),
|
||||
color-mix(in srgb, var(--bg-elevated) 52%, var(--card) 48%);
|
||||
}
|
||||
|
||||
.qs-identity-card__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qs-personal-preview__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-strong);
|
||||
.qs-identity-card__eyebrow {
|
||||
margin-bottom: 2px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
.qs-identity-card__title {
|
||||
color: var(--text-strong);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.qs-identity-card__sub {
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.qs-identity-card__source {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin-top: 10px;
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 45%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.qs-identity-card__source code {
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.qs-identity-card__issue {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin-top: 7px;
|
||||
padding: 4px 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning, #f7b955) 35%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--warning, #f7b955) 10%, transparent);
|
||||
color: color-mix(in srgb, var(--warning, #f7b955) 82%, var(--text) 18%);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.qs-identity-card__repair {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.qs-identity-card__repair .btn {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.qs-identity-card__repair .muted {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.qs-identity-card__error {
|
||||
margin-top: 8px;
|
||||
color: var(--danger, #ff6b78);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.qs-user-avatar,
|
||||
.qs-assistant-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: calc(var(--radius-md) + 2px);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.16);
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.qs-user-avatar--text,
|
||||
.qs-user-avatar--default {
|
||||
.qs-user-avatar--default,
|
||||
.qs-assistant-avatar--text {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent-subtle);
|
||||
@@ -245,6 +368,12 @@
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.qs-assistant-avatar--fallback {
|
||||
padding: 7px;
|
||||
background: color-mix(in srgb, var(--bg) 84%, var(--bg-elevated) 16%);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.qs-user-avatar--default svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -283,9 +412,14 @@
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--ease-out);
|
||||
transition:
|
||||
color var(--duration-normal) var(--ease-out),
|
||||
background var(--duration-normal) var(--ease-out),
|
||||
box-shadow var(--duration-normal) var(--ease-out),
|
||||
transform var(--duration-normal) var(--ease-out);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.qs-segmented__btn--compact {
|
||||
@@ -296,6 +430,12 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.qs-segmented__btn:focus-visible {
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.qs-segmented__btn--active {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-strong);
|
||||
@@ -429,6 +569,12 @@
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.qs-link-btn:focus-visible {
|
||||
outline: none;
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Empty state ── */
|
||||
|
||||
.qs-empty {
|
||||
@@ -588,78 +734,342 @@
|
||||
|
||||
.qs-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 12px 16px !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.qs-profiles {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(280px, 0.9fr);
|
||||
gap: 18px;
|
||||
padding: 18px 16px 16px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--accent-subtle) 18%, transparent) 0%,
|
||||
transparent 36%
|
||||
);
|
||||
}
|
||||
|
||||
.qs-profiles__copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.qs-profiles__eyebrow {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--accent) 68%, var(--muted) 32%);
|
||||
}
|
||||
|
||||
.qs-profiles__intro {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.qs-profile-state {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 72%, var(--card) 28%);
|
||||
}
|
||||
|
||||
.qs-profile-state--pending {
|
||||
border-color: color-mix(in srgb, var(--accent) 28%, var(--border) 72%);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 42%, var(--card) 58%);
|
||||
}
|
||||
|
||||
.qs-profile-state--pending .qs-status-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
}
|
||||
|
||||
.qs-profile-state--ok {
|
||||
border-color: color-mix(in srgb, var(--ok) 24%, var(--border) 76%);
|
||||
background: color-mix(in srgb, var(--ok-subtle) 58%, var(--card) 42%);
|
||||
}
|
||||
|
||||
.qs-profile-state__text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.qs-profile-state__title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.qs-profile-state__copy {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-preset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--duration-normal) var(--ease-out),
|
||||
background var(--duration-normal) var(--ease-out),
|
||||
box-shadow var(--duration-normal) var(--ease-out);
|
||||
box-shadow var(--duration-normal) var(--ease-out),
|
||||
transform var(--duration-normal) var(--ease-out);
|
||||
text-align: left;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.qs-preset:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border) 60%);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 40%, var(--card) 60%);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.qs-preset:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--border) 45%);
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent),
|
||||
0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.qs-preset--active {
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--border) 50%);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 60%, var(--card) 40%);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent),
|
||||
0 12px 28px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.qs-preset--active:hover {
|
||||
background: color-mix(in srgb, var(--accent-subtle) 80%, var(--card) 20%);
|
||||
}
|
||||
|
||||
.qs-preset__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qs-preset__identity {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qs-preset__identity-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qs-preset__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qs-preset__icon {
|
||||
font-size: 1.35rem;
|
||||
font-size: 1.45rem;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.qs-preset__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.qs-preset__desc {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.qs-preset__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.qs-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.qs-preset__meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 84%, transparent);
|
||||
}
|
||||
|
||||
.qs-profile-panel {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-elevated) 76%, var(--card) 24%);
|
||||
}
|
||||
|
||||
.qs-profile-panel__eyebrow {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--accent) 68%, var(--muted) 32%);
|
||||
}
|
||||
|
||||
.qs-profile-panel__title {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-strong);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.qs-profile-panel__copy {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.qs-profile-panel__impact {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-profile-panel__stats {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.qs-profile-stat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 62%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--card) 84%, transparent);
|
||||
}
|
||||
|
||||
.qs-profile-stat--changed {
|
||||
border-color: color-mix(in srgb, var(--accent) 32%, var(--border) 68%);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 44%, var(--card) 56%);
|
||||
}
|
||||
|
||||
.qs-profile-stat__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.qs-profile-stat__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-profile-stat__value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-strong);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.qs-profile-stat__sub {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.qs-profile-stat--changed .qs-profile-stat__sub {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.qs-profile-stat__note {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.qs-profile-panel__actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
||||
}
|
||||
|
||||
.qs-profile-panel__actions-copy,
|
||||
.qs-profile-panel__footer {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.qs-profile-panel__actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.qs-profile-panel__footer {
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.qs-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.qs-card--personal,
|
||||
.qs-stack--wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.qs-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qs-card--personal,
|
||||
.qs-stack--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -667,7 +1077,7 @@
|
||||
padding: 20px 0 40px;
|
||||
}
|
||||
|
||||
.qs-presets-grid {
|
||||
.qs-identity-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -679,3 +1089,24 @@
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.qs-profiles {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.qs-presets-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qs-profile-panel__actions-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.qs-profile-panel__actions-row .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("config-quick personal identity styles", () => {
|
||||
it("includes the local user identity quick-settings styles", () => {
|
||||
const css = readFileSync(new URL("./config-quick.css", import.meta.url), "utf8");
|
||||
const css = readFileSync(new URL("./config-quick.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toContain(".qs-personal-preview");
|
||||
describe("config-quick styles", () => {
|
||||
it("includes the local user identity quick-settings styles", () => {
|
||||
expect(css).toContain(".qs-identity-grid");
|
||||
expect(css).toContain(".qs-identity-card__source");
|
||||
expect(css).toContain(".qs-identity-card__issue");
|
||||
expect(css).toContain(".qs-identity-card__repair");
|
||||
expect(css).toContain(".qs-identity-card__error");
|
||||
expect(css).toContain(".qs-assistant-avatar");
|
||||
expect(css).toContain(".qs-user-avatar");
|
||||
expect(css).toContain(".qs-personal-actions");
|
||||
expect(css).toContain(".qs-card--personal");
|
||||
});
|
||||
|
||||
it("includes the stacked quick-settings density layout", () => {
|
||||
const css = readFileSync(new URL("./config-quick.css", import.meta.url), "utf8");
|
||||
|
||||
expect(css).toContain(".qs-stack");
|
||||
expect(css).toContain("grid-template-columns: repeat(4, minmax(0, 1fr));");
|
||||
expect(css).toContain("@media (max-width: 1380px)");
|
||||
expect(css).toContain(".qs-stack--wide");
|
||||
expect(css).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
|
||||
expect(css).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
|
||||
expect(css).toContain("@media (max-width: 760px)");
|
||||
});
|
||||
|
||||
it("includes explicit context profile layout hooks", () => {
|
||||
expect(css).toContain(".qs-profiles");
|
||||
expect(css).toContain(".qs-profile-state--pending");
|
||||
expect(css).toContain(".qs-profile-panel__actions-row");
|
||||
});
|
||||
|
||||
it("avoids transition-all in the quick settings surface", () => {
|
||||
expect(css).not.toContain("transition: all");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,9 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
|
||||
basePath: "",
|
||||
hello: null,
|
||||
chatAvatarUrl: null,
|
||||
chatAvatarSource: null,
|
||||
chatAvatarStatus: null,
|
||||
chatAvatarReason: null,
|
||||
chatSideResult: null,
|
||||
chatSideResultTerminalRuns: new Set<string>(),
|
||||
chatModelOverrides: {},
|
||||
@@ -281,7 +284,12 @@ describe("refreshChatAvatar", () => {
|
||||
it("drops remote avatar metadata so the control UI can rely on same-origin images only", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ avatarUrl: "https://example.com/avatar.png" }),
|
||||
json: async () => ({
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
avatarSource: "https://example.com/avatar.png",
|
||||
avatarStatus: "remote",
|
||||
avatarReason: null,
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
@@ -289,6 +297,29 @@ describe("refreshChatAvatar", () => {
|
||||
await refreshChatAvatar(host);
|
||||
|
||||
expect(host.chatAvatarUrl).toBeNull();
|
||||
expect(host.chatAvatarSource).toBe("https://example.com/avatar.png");
|
||||
expect(host.chatAvatarStatus).toBe("remote");
|
||||
});
|
||||
|
||||
it("keeps unresolved IDENTITY.md avatar metadata when falling back to the logo", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
avatarUrl: null,
|
||||
avatarSource: "assets/avatars/nova-portrait.png",
|
||||
avatarStatus: "none",
|
||||
avatarReason: "missing",
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const host = makeHost({ basePath: "", sessionKey: "agent:main" });
|
||||
await refreshChatAvatar(host);
|
||||
|
||||
expect(host.chatAvatarUrl).toBeNull();
|
||||
expect(host.chatAvatarSource).toBe("assets/avatars/nova-portrait.png");
|
||||
expect(host.chatAvatarStatus).toBe("none");
|
||||
expect(host.chatAvatarReason).toBe("missing");
|
||||
});
|
||||
|
||||
it("ignores stale avatar responses after switching sessions", async () => {
|
||||
|
||||
@@ -42,6 +42,9 @@ export type ChatHost = {
|
||||
password?: string | null;
|
||||
hello: GatewayHelloOk | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatAvatarSource?: string | null;
|
||||
chatAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
chatAvatarReason?: string | null;
|
||||
chatSideResult?: ChatSideResult | null;
|
||||
chatSideResultTerminalRuns?: Set<string>;
|
||||
chatModelOverrides: Record<string, ChatModelOverride | null>;
|
||||
@@ -596,6 +599,13 @@ function clearChatAvatarUrl(host: ChatHost) {
|
||||
host.chatAvatarUrl = null;
|
||||
}
|
||||
|
||||
function clearChatAvatarState(host: ChatHost) {
|
||||
clearChatAvatarUrl(host);
|
||||
host.chatAvatarSource = null;
|
||||
host.chatAvatarStatus = null;
|
||||
host.chatAvatarReason = null;
|
||||
}
|
||||
|
||||
function setChatAvatarUrl(host: ChatHost, nextUrl: string | null) {
|
||||
const key = host as object;
|
||||
const previousBlobUrl = chatAvatarObjectUrls.get(key);
|
||||
@@ -609,6 +619,32 @@ function setChatAvatarUrl(host: ChatHost, nextUrl: string | null) {
|
||||
host.chatAvatarUrl = nextUrl;
|
||||
}
|
||||
|
||||
function setChatAvatarMeta(
|
||||
host: ChatHost,
|
||||
data: {
|
||||
avatarSource?: unknown;
|
||||
avatarStatus?: unknown;
|
||||
avatarReason?: unknown;
|
||||
},
|
||||
) {
|
||||
const status =
|
||||
data.avatarStatus === "none" ||
|
||||
data.avatarStatus === "local" ||
|
||||
data.avatarStatus === "remote" ||
|
||||
data.avatarStatus === "data"
|
||||
? data.avatarStatus
|
||||
: null;
|
||||
host.chatAvatarSource =
|
||||
typeof data.avatarSource === "string" && data.avatarSource.trim()
|
||||
? data.avatarSource.trim()
|
||||
: null;
|
||||
host.chatAvatarStatus = status;
|
||||
host.chatAvatarReason =
|
||||
typeof data.avatarReason === "string" && data.avatarReason.trim()
|
||||
? data.avatarReason.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildControlUiAuthHeaders(authHeader: string | null): Record<string, string> | undefined {
|
||||
return authHeader ? { Authorization: authHeader } : undefined;
|
||||
}
|
||||
@@ -619,7 +655,7 @@ function isLocalControlUiAvatarUrl(avatarUrl: string): boolean {
|
||||
|
||||
export async function refreshChatAvatar(host: ChatHost) {
|
||||
if (!host.connected) {
|
||||
clearChatAvatarUrl(host);
|
||||
clearChatAvatarState(host);
|
||||
return;
|
||||
}
|
||||
const sessionKey = host.sessionKey;
|
||||
@@ -627,11 +663,11 @@ export async function refreshChatAvatar(host: ChatHost) {
|
||||
const agentId = resolveAgentIdForSession(host);
|
||||
if (!agentId) {
|
||||
if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
|
||||
clearChatAvatarUrl(host);
|
||||
clearChatAvatarState(host);
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearChatAvatarUrl(host);
|
||||
clearChatAvatarState(host);
|
||||
const authHeader = resolveControlUiAuthHeader(host);
|
||||
const headers = buildControlUiAuthHeaders(authHeader);
|
||||
const url = buildAvatarMetaUrl(host.basePath, agentId);
|
||||
@@ -641,13 +677,19 @@ export async function refreshChatAvatar(host: ChatHost) {
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
clearChatAvatarUrl(host);
|
||||
clearChatAvatarState(host);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { avatarUrl?: unknown };
|
||||
const data = (await res.json()) as {
|
||||
avatarUrl?: unknown;
|
||||
avatarSource?: unknown;
|
||||
avatarStatus?: unknown;
|
||||
avatarReason?: unknown;
|
||||
};
|
||||
if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
|
||||
return;
|
||||
}
|
||||
setChatAvatarMeta(host, data);
|
||||
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
|
||||
if (!avatarUrl || !isRenderableControlUiAvatarUrl(avatarUrl)) {
|
||||
clearChatAvatarUrl(host);
|
||||
@@ -675,7 +717,7 @@ export async function refreshChatAvatar(host: ChatHost) {
|
||||
setChatAvatarUrl(host, blobUrl);
|
||||
} catch {
|
||||
if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) {
|
||||
clearChatAvatarUrl(host);
|
||||
clearChatAvatarState(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const loadChatHistoryMock = vi.fn();
|
||||
vi.mock("./app-chat.ts", () => ({
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES: 10,
|
||||
flushChatQueueForEvent: vi.fn(),
|
||||
refreshChatAvatar: vi.fn(),
|
||||
}));
|
||||
vi.mock("./app-settings.ts", () => ({
|
||||
applySettings: vi.fn(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||
clearPendingQueueItemsForRun,
|
||||
flushChatQueueForEvent,
|
||||
refreshChatAvatar,
|
||||
} from "./app-chat.ts";
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import {
|
||||
@@ -335,6 +336,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
|
||||
}
|
||||
void subscribeSessions(host as unknown as SessionsState);
|
||||
void loadAssistantIdentity(host as unknown as AssistantIdentityState);
|
||||
void refreshChatAvatar(host as unknown as Parameters<typeof refreshChatAvatar>[0]);
|
||||
void loadAgents(host as unknown as AgentsState);
|
||||
void loadHealthState(host as unknown as HealthState);
|
||||
void loadNodes(host as unknown as NodesState, { quiet: true });
|
||||
|
||||
@@ -26,6 +26,9 @@ type LifecycleHost = {
|
||||
tab: Tab;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
assistantAgentId: string | null;
|
||||
serverVersion: string | null;
|
||||
localMediaPreviewRoots: string[];
|
||||
|
||||
@@ -80,6 +80,9 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
|
||||
state.compactionStatus = null;
|
||||
state.fallbackStatus = null;
|
||||
state.chatAvatarUrl = null;
|
||||
state.chatAvatarSource = null;
|
||||
state.chatAvatarStatus = null;
|
||||
state.chatAvatarReason = null;
|
||||
state.chatQueue = [];
|
||||
host.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { applyMergePatch } from "../../../src/config/merge-patch.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { getSafeLocalStorage } from "../local-storage.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import { refreshChat, refreshChatAvatar } from "./app-chat.ts";
|
||||
import { DEFAULT_CRON_FORM } from "./app-defaults.ts";
|
||||
import { renderUsageTab } from "./app-render-usage-tab.ts";
|
||||
import {
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
refreshVisibleToolsEffectiveForCurrentSession,
|
||||
saveAgentsConfig,
|
||||
} from "./controllers/agents.ts";
|
||||
import { setAssistantAvatarOverride } from "./controllers/assistant-identity.ts";
|
||||
import { loadChannels } from "./controllers/channels.ts";
|
||||
import { loadChatHistory } from "./controllers/chat.ts";
|
||||
import {
|
||||
@@ -40,10 +40,10 @@ import {
|
||||
resetConfigPendingChanges,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
stageConfigPreset,
|
||||
updateConfigFormValue,
|
||||
removeConfigFormValue,
|
||||
} from "./controllers/config.ts";
|
||||
import { cloneConfigObject, serializeConfigForm } from "./controllers/config/form-utils.ts";
|
||||
import {
|
||||
loadCronJobsPage,
|
||||
loadCronRuns,
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "./session-key.ts";
|
||||
import { normalizeOptionalString } from "./string-coerce.ts";
|
||||
import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts";
|
||||
import { agentLogoUrl } from "./views/agents-utils.ts";
|
||||
import {
|
||||
@@ -134,7 +135,7 @@ import {
|
||||
} from "./views/agents-utils.ts";
|
||||
import { renderChat } from "./views/chat.ts";
|
||||
import { renderCommandPalette } from "./views/command-palette.ts";
|
||||
import { getPresetById, type ConfigPresetId } from "./views/config-presets.ts";
|
||||
import { getPresetById } from "./views/config-presets.ts";
|
||||
import { renderQuickSettings, type QuickSettingsChannel } from "./views/config-quick.ts";
|
||||
import { renderConfig, type ConfigProps } from "./views/config.ts";
|
||||
import {
|
||||
@@ -426,6 +427,27 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAssistantAvatarOverride(config: unknown): string | null {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return null;
|
||||
}
|
||||
const ui = (config as { ui?: unknown }).ui;
|
||||
if (!ui || typeof ui !== "object" || Array.isArray(ui)) {
|
||||
return null;
|
||||
}
|
||||
const assistant = (ui as { assistant?: unknown }).assistant;
|
||||
if (!assistant || typeof assistant !== "object" || Array.isArray(assistant)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeOptionalString((assistant as { avatar?: unknown }).avatar) ?? null;
|
||||
}
|
||||
|
||||
function buildAssistantAvatarRoute(basePathValue: string | null | undefined, agentId: string) {
|
||||
const basePath = normalizeBasePath(basePathValue ?? "");
|
||||
const encoded = encodeURIComponent(agentId);
|
||||
return basePath ? `${basePath}/avatar/${encoded}` : `/avatar/${encoded}`;
|
||||
}
|
||||
|
||||
// ── Quick Settings data extraction helpers ──
|
||||
|
||||
const KNOWN_CHANNEL_IDS = [
|
||||
@@ -563,35 +585,6 @@ function resolveQuickSettingsSessionRow(state: AppViewState) {
|
||||
return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
|
||||
}
|
||||
|
||||
async function applyQuickSettingsPreset(state: AppViewState, presetId: ConfigPresetId) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
const preset = getPresetById(presetId);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
state.configApplying = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
if (!state.configSnapshot?.hash) {
|
||||
await loadConfig(state);
|
||||
}
|
||||
const baseHash = state.configSnapshot?.hash?.trim();
|
||||
if (!baseHash) {
|
||||
throw new Error("Config base hash unavailable. Reload config and retry.");
|
||||
}
|
||||
const baseConfig = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
|
||||
const merged = applyMergePatch(baseConfig, preset.patch) as Record<string, unknown>;
|
||||
await state.client.request("config.patch", { raw: serializeConfigForm(merged), baseHash });
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
state.lastError = `Failed to apply preset: ${String(err)}`;
|
||||
} finally {
|
||||
state.configApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCronQuickCreateForTab(
|
||||
state: AppViewState,
|
||||
requestHostUpdate: (() => void) | undefined,
|
||||
@@ -663,7 +656,27 @@ export function renderApp(state: AppViewState) {
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
|
||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||
const chatAssistantAvatarStatus = state.chatAvatarStatus ?? state.assistantAvatarStatus ?? null;
|
||||
const chatAssistantAvatarReason = state.chatAvatarReason ?? state.assistantAvatarReason ?? null;
|
||||
const chatAssistantAvatarMissing =
|
||||
chatAssistantAvatarStatus === "none" && chatAssistantAvatarReason === "missing";
|
||||
const effectiveAssistantAvatar = chatAssistantAvatarMissing ? null : state.assistantAvatar;
|
||||
const chatAvatarUrl =
|
||||
state.chatAvatarUrl ?? (chatAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null));
|
||||
const configAssistantAvatarStatus = state.assistantAvatarStatus ?? state.chatAvatarStatus ?? null;
|
||||
const configAssistantAvatarReason = state.assistantAvatarReason ?? state.chatAvatarReason ?? null;
|
||||
const configAssistantAvatarSource = state.assistantAvatarSource ?? state.chatAvatarSource ?? null;
|
||||
const configAssistantAvatarMissing =
|
||||
configAssistantAvatarStatus === "none" && configAssistantAvatarReason === "missing";
|
||||
const configAssistantAvatar =
|
||||
configAssistantAvatarMissing || configAssistantAvatarStatus === "local"
|
||||
? null
|
||||
: state.assistantAvatar;
|
||||
const configAssistantAvatarUrl =
|
||||
configAssistantAvatarStatus === "local" && state.assistantAgentId
|
||||
? buildAssistantAvatarRoute(state.basePath, state.assistantAgentId)
|
||||
: (state.chatAvatarUrl ??
|
||||
(configAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)));
|
||||
const configValue =
|
||||
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
||||
const configuredDreaming = resolveConfiguredDreaming(configValue);
|
||||
@@ -928,6 +941,7 @@ export function renderApp(state: AppViewState) {
|
||||
// Quick Settings mode — opinionated card layout
|
||||
if (state.configSettingsMode === "quick") {
|
||||
const configObj = state.configForm ?? state.configSnapshot?.config ?? {};
|
||||
const assistantAvatarOverride = resolveAssistantAvatarOverride(configObj);
|
||||
const agentsDefaults = ((configObj.agents as Record<string, unknown> | undefined)
|
||||
?.defaults ?? {}) as Record<string, unknown>;
|
||||
const activeSession = resolveQuickSettingsSessionRow(state);
|
||||
@@ -1014,14 +1028,79 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
setThemeMode: (mode, context) => state.setThemeMode(mode, context),
|
||||
setBorderRadius: (value) => state.setBorderRadius(value),
|
||||
userName: state.userName ?? null,
|
||||
userAvatar: state.userAvatar ?? null,
|
||||
onUserNameChange: (name) => state.applyLocalUserIdentity?.({ name }),
|
||||
onUserAvatarChange: (avatar) => state.applyLocalUserIdentity?.({ avatar }),
|
||||
configObject: configObj,
|
||||
onApplyPreset: (presetId) => {
|
||||
void applyQuickSettingsPreset(state, presetId).then(() => requestHostUpdate?.());
|
||||
assistantAvatar: configAssistantAvatar,
|
||||
assistantAvatarUrl: configAssistantAvatarUrl,
|
||||
assistantAvatarSource: configAssistantAvatarSource,
|
||||
assistantAvatarStatus: configAssistantAvatarStatus,
|
||||
assistantAvatarReason: configAssistantAvatarReason,
|
||||
assistantAvatarOverride,
|
||||
assistantAvatarUploadBusy: state.assistantAvatarUploadBusy,
|
||||
assistantAvatarUploadError: state.assistantAvatarUploadError,
|
||||
onAssistantAvatarOverrideChange: async (dataUrl) => {
|
||||
state.assistantAvatarUploadBusy = true;
|
||||
state.assistantAvatarUploadError = null;
|
||||
requestHostUpdate?.();
|
||||
try {
|
||||
await setAssistantAvatarOverride(state, dataUrl);
|
||||
state.assistantAvatar = dataUrl;
|
||||
state.assistantAvatarSource = dataUrl;
|
||||
state.assistantAvatarStatus = "data";
|
||||
state.assistantAvatarReason = null;
|
||||
state.chatAvatarUrl = dataUrl;
|
||||
state.chatAvatarSource = dataUrl;
|
||||
state.chatAvatarStatus = "data";
|
||||
state.chatAvatarReason = null;
|
||||
await loadConfig(state);
|
||||
await state.loadAssistantIdentity();
|
||||
await refreshChatAvatar(state);
|
||||
} catch (err) {
|
||||
state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.assistantAvatarUploadBusy = false;
|
||||
requestHostUpdate?.();
|
||||
}
|
||||
},
|
||||
onAssistantAvatarClearOverride: async () => {
|
||||
state.assistantAvatarUploadBusy = true;
|
||||
state.assistantAvatarUploadError = null;
|
||||
requestHostUpdate?.();
|
||||
try {
|
||||
await setAssistantAvatarOverride(state, null);
|
||||
state.chatAvatarUrl = null;
|
||||
state.chatAvatarSource = null;
|
||||
state.chatAvatarStatus = null;
|
||||
state.chatAvatarReason = null;
|
||||
await loadConfig(state);
|
||||
await state.loadAssistantIdentity();
|
||||
await refreshChatAvatar(state);
|
||||
} catch (err) {
|
||||
state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.assistantAvatarUploadBusy = false;
|
||||
requestHostUpdate?.();
|
||||
}
|
||||
},
|
||||
basePath: state.basePath ?? "",
|
||||
configObject: configObj,
|
||||
savedConfigObject:
|
||||
(state.configSnapshot?.config as Record<string, unknown> | null) ?? {},
|
||||
configDirty: state.configFormDirty,
|
||||
configSaving: state.configSaving,
|
||||
configApplying: state.configApplying,
|
||||
configReady: Boolean(state.configSnapshot?.hash),
|
||||
onSelectPreset: (presetId) => {
|
||||
const preset = getPresetById(presetId);
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
stageConfigPreset(state, preset.patch);
|
||||
requestHostUpdate?.();
|
||||
},
|
||||
onResetConfig: () => resetConfigPendingChanges(state),
|
||||
onSaveConfig: () => saveConfig(state),
|
||||
onApplyConfig: () => applyConfig(state),
|
||||
onAdvancedSettings: () => {
|
||||
state.configSettingsMode = "advanced";
|
||||
requestHostUpdate?.();
|
||||
@@ -2322,7 +2401,7 @@ export function renderApp(state: AppViewState) {
|
||||
onCloseSidebar: () => state.handleCloseSidebar(),
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
assistantAvatar: effectiveAssistantAvatar,
|
||||
userName: state.userName ?? null,
|
||||
userAvatar: state.userAvatar ?? null,
|
||||
localMediaPreviewRoots: state.localMediaPreviewRoots,
|
||||
|
||||
@@ -71,6 +71,11 @@ export type AppViewState = {
|
||||
eventLog: EventLogEntry[];
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
assistantAvatarUploadBusy: boolean;
|
||||
assistantAvatarUploadError: string | null;
|
||||
assistantAgentId: string | null;
|
||||
userName?: string | null;
|
||||
userAvatar?: string | null;
|
||||
@@ -93,6 +98,9 @@ export type AppViewState = {
|
||||
compactionStatus: CompactionStatus | null;
|
||||
fallbackStatus: FallbackStatus | null;
|
||||
chatAvatarUrl: string | null;
|
||||
chatAvatarSource?: string | null;
|
||||
chatAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
chatAvatarReason?: string | null;
|
||||
chatThinkingLevel: string | null;
|
||||
chatModelOverrides: Record<string, ChatModelOverride | null>;
|
||||
chatModelsLoading: boolean;
|
||||
|
||||
@@ -171,6 +171,11 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
@state() assistantName = bootAssistantIdentity.name;
|
||||
@state() assistantAvatar = bootAssistantIdentity.avatar;
|
||||
@state() assistantAvatarSource = bootAssistantIdentity.avatarSource ?? null;
|
||||
@state() assistantAvatarStatus = bootAssistantIdentity.avatarStatus ?? null;
|
||||
@state() assistantAvatarReason = bootAssistantIdentity.avatarReason ?? null;
|
||||
@state() assistantAvatarUploadBusy = false;
|
||||
@state() assistantAvatarUploadError: string | null = null;
|
||||
@state() assistantAgentId = bootAssistantIdentity.agentId ?? null;
|
||||
@state() userName = bootLocalUserIdentity.name;
|
||||
@state() userAvatar = bootLocalUserIdentity.avatar;
|
||||
@@ -193,6 +198,9 @@ export class OpenClawApp extends LitElement {
|
||||
@state() compactionStatus: CompactionStatus | null = null;
|
||||
@state() fallbackStatus: FallbackStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatAvatarSource: string | null = null;
|
||||
@state() chatAvatarStatus: "none" | "local" | "remote" | "data" | null = null;
|
||||
@state() chatAvatarReason: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatModelOverrides: Record<string, ChatModelOverride | null> = {};
|
||||
@state() chatModelsLoading = false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { coerceIdentityValue } from "../../../src/shared/assistant-identity-valu
|
||||
|
||||
const MAX_ASSISTANT_NAME = 50;
|
||||
const MAX_ASSISTANT_AVATAR = 200;
|
||||
const MAX_ASSISTANT_AVATAR_SOURCE = 500;
|
||||
|
||||
export const DEFAULT_ASSISTANT_NAME = "Assistant";
|
||||
export const DEFAULT_ASSISTANT_AVATAR = "A";
|
||||
@@ -10,6 +11,9 @@ export type AssistantIdentity = {
|
||||
agentId?: string | null;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
avatarSource?: string | null;
|
||||
avatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
avatarReason?: string | null;
|
||||
};
|
||||
|
||||
export function normalizeAssistantIdentity(
|
||||
@@ -17,7 +21,18 @@ export function normalizeAssistantIdentity(
|
||||
): AssistantIdentity {
|
||||
const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
|
||||
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
|
||||
const avatarSource =
|
||||
coerceIdentityValue(input?.avatarSource ?? undefined, MAX_ASSISTANT_AVATAR_SOURCE) ?? null;
|
||||
const avatarStatus =
|
||||
input?.avatarStatus === "none" ||
|
||||
input?.avatarStatus === "local" ||
|
||||
input?.avatarStatus === "remote" ||
|
||||
input?.avatarStatus === "data"
|
||||
? input.avatarStatus
|
||||
: null;
|
||||
const avatarReason =
|
||||
coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
|
||||
const agentId =
|
||||
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
|
||||
return { agentId, name, avatar };
|
||||
return { agentId, name, avatar, avatarSource, avatarStatus, avatarReason };
|
||||
}
|
||||
|
||||
@@ -26,28 +26,48 @@ vi.mock("../icons.ts", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../views/agents-utils.ts", () => ({
|
||||
agentLogoUrl: () => "/openclaw-logo.svg",
|
||||
isRenderableControlUiAvatarUrl: (value: string) =>
|
||||
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")),
|
||||
resolveChatAvatarRenderUrl: (
|
||||
candidate: string | null | undefined,
|
||||
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||
) => {
|
||||
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
|
||||
return candidate;
|
||||
}
|
||||
for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
(/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//")))
|
||||
) {
|
||||
return value;
|
||||
vi.mock("../views/agents-utils.ts", () => {
|
||||
const isRenderableControlUiAvatarUrl = (value: string) =>
|
||||
/^data:image\//i.test(value) || (value.startsWith("/") && !value.startsWith("//"));
|
||||
|
||||
return {
|
||||
assistantAvatarFallbackUrl: () => "/openclaw-molty.png",
|
||||
agentLogoUrl: () => "/openclaw-logo.svg",
|
||||
isRenderableControlUiAvatarUrl,
|
||||
resolveAssistantTextAvatar: (value: string | null | undefined) => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === "A") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
if (trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
trimmed.length > 8 ||
|
||||
/\s/.test(trimmed) ||
|
||||
/[\\/.:]/.test(trimmed) ||
|
||||
/[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u.test(trimmed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
resolveChatAvatarRenderUrl: (
|
||||
candidate: string | null | undefined,
|
||||
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||
) => {
|
||||
if (typeof candidate === "string" && candidate.startsWith("blob:")) {
|
||||
return candidate;
|
||||
}
|
||||
for (const value of [candidate, agent.identity?.avatarUrl, agent.identity?.avatar]) {
|
||||
if (typeof value === "string" && isRenderableControlUiAvatarUrl(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tool-display.ts", () => ({
|
||||
formatToolDetail: () => undefined,
|
||||
@@ -216,7 +236,21 @@ describe("grouped chat rendering", () => {
|
||||
);
|
||||
|
||||
const img = container.querySelector("img.chat-avatar");
|
||||
expect(img?.getAttribute("src")).toBe("/openclaw-logo.svg");
|
||||
expect(img?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("uses the Molty png as the default assistant transcript avatar", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
renderAssistantMessage(container, {
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(".chat-avatar.assistant");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
});
|
||||
|
||||
it("positions delete confirm by message side", () => {
|
||||
@@ -279,7 +313,7 @@ describe("grouped chat rendering", () => {
|
||||
};
|
||||
|
||||
const remoteAvatar = renderAvatar("https://example.com/avatar.png");
|
||||
expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-logo.svg");
|
||||
expect(remoteAvatar?.getAttribute("src")).toBe("/openclaw-molty.png");
|
||||
|
||||
const blobAvatar = renderAvatar("blob:managed-image");
|
||||
expect(blobAvatar?.tagName).toBe("IMG");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import { DEFAULT_ASSISTANT_AVATAR, type AssistantIdentity } from "../assistant-identity.ts";
|
||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
resolveLocalUserAvatarUrl,
|
||||
resolveLocalUserName,
|
||||
} from "../user-identity.ts";
|
||||
import { agentLogoUrl, isRenderableControlUiAvatarUrl } from "../views/agents-utils.ts";
|
||||
import {
|
||||
assistantAvatarFallbackUrl,
|
||||
isRenderableControlUiAvatarUrl,
|
||||
resolveAssistantTextAvatar,
|
||||
} from "../views/agents-utils.ts";
|
||||
export { resolveAssistantTextAvatar } from "../views/agents-utils.ts";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||
import {
|
||||
extractTextCached,
|
||||
@@ -685,6 +690,7 @@ function renderAvatar(
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar);
|
||||
const assistantFallbackAvatar = assistantAvatarFallbackUrl(basePath ?? "");
|
||||
const userName = resolveLocalUserName(user);
|
||||
const userAvatarUrl = resolveLocalUserAvatarUrl(user);
|
||||
const userAvatarText = resolveLocalUserAvatarText(user);
|
||||
@@ -749,7 +755,7 @@ function renderAvatar(
|
||||
if (authToken?.trim() && assistantAvatar.startsWith("/")) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${agentLogoUrl(basePath ?? "")}"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
@@ -766,17 +772,15 @@ function renderAvatar(
|
||||
}
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${agentLogoUrl(basePath ?? "")}"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
/* Assistant with no custom avatar: use logo when basePath available */
|
||||
if (normalized === "assistant" && basePath) {
|
||||
const logoUrl = agentLogoUrl(basePath);
|
||||
if (normalized === "assistant") {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${logoUrl}"
|
||||
src="${assistantFallbackAvatar}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
@@ -789,27 +793,6 @@ function isAvatarUrl(value: string): boolean {
|
||||
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
|
||||
}
|
||||
|
||||
const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
|
||||
|
||||
export function resolveAssistantTextAvatar(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) {
|
||||
return null;
|
||||
}
|
||||
if (isAvatarUrl(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
trimmed.length > 8 ||
|
||||
/\s/.test(trimmed) ||
|
||||
/[\\/.:]/.test(trimmed) ||
|
||||
UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveRenderableMessageImages(
|
||||
images: ImageBlock[],
|
||||
opts?: ImageRenderOptions,
|
||||
|
||||
45
ui/src/ui/controllers/assistant-identity.test.ts
Normal file
45
ui/src/ui/controllers/assistant-identity.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { setAssistantAvatarOverride } from "./assistant-identity.ts";
|
||||
|
||||
describe("setAssistantAvatarOverride", () => {
|
||||
it("writes the assistant avatar override through config.patch", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
|
||||
await setAssistantAvatarOverride(
|
||||
{
|
||||
client: { request } as never,
|
||||
connected: true,
|
||||
applySessionKey: "agent:main",
|
||||
configSnapshot: { hash: "config-hash" },
|
||||
},
|
||||
"data:image/png;base64,YXZhdGFy",
|
||||
);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config.patch", {
|
||||
baseHash: "config-hash",
|
||||
raw: JSON.stringify({ ui: { assistant: { avatar: "data:image/png;base64,YXZhdGFy" } } }),
|
||||
sessionKey: "agent:main",
|
||||
note: "Assistant avatar override updated from Control UI.",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the assistant avatar override through config.patch", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
|
||||
await setAssistantAvatarOverride(
|
||||
{
|
||||
client: { request } as never,
|
||||
connected: true,
|
||||
configSnapshot: { hash: "config-hash" },
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config.patch", {
|
||||
baseHash: "config-hash",
|
||||
raw: JSON.stringify({ ui: { assistant: { avatar: null } } }),
|
||||
sessionKey: undefined,
|
||||
note: "Assistant avatar override cleared from Control UI.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,9 +7,19 @@ export type AssistantIdentityState = {
|
||||
sessionKey: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
assistantAgentId: string | null;
|
||||
};
|
||||
|
||||
export type AssistantAvatarOverrideState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
applySessionKey?: string;
|
||||
configSnapshot?: { hash?: string | null } | null;
|
||||
};
|
||||
|
||||
export async function loadAssistantIdentity(
|
||||
state: AssistantIdentityState,
|
||||
opts?: { sessionKey?: string },
|
||||
@@ -27,8 +37,32 @@ export async function loadAssistantIdentity(
|
||||
const normalized = normalizeAssistantIdentity(res);
|
||||
state.assistantName = normalized.name;
|
||||
state.assistantAvatar = normalized.avatar;
|
||||
state.assistantAvatarSource = normalized.avatarSource ?? null;
|
||||
state.assistantAvatarStatus = normalized.avatarStatus ?? null;
|
||||
state.assistantAvatarReason = normalized.avatarReason ?? null;
|
||||
state.assistantAgentId = normalized.agentId ?? null;
|
||||
} catch {
|
||||
// Ignore errors; keep last known identity.
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAssistantAvatarOverride(
|
||||
state: AssistantAvatarOverrideState,
|
||||
avatar: string | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
throw new Error("Gateway is not connected.");
|
||||
}
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
throw new Error("Config hash missing; refresh and retry.");
|
||||
}
|
||||
await state.client.request("config.patch", {
|
||||
baseHash,
|
||||
raw: JSON.stringify({ ui: { assistant: { avatar } } }),
|
||||
sessionKey: state.applySessionKey,
|
||||
note: avatar
|
||||
? "Assistant avatar override updated from Control UI."
|
||||
: "Assistant avatar override cleared from Control UI.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resetConfigPendingChanges,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
stageConfigPreset,
|
||||
updateConfigFormValue,
|
||||
type ConfigState,
|
||||
} from "./config.ts";
|
||||
@@ -162,6 +163,114 @@ describe("updateConfigFormValue", () => {
|
||||
'{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
|
||||
);
|
||||
});
|
||||
|
||||
it("clears dirty when a form edit returns to the original value", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: { gateway: { mode: "local", port: 18789 } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
|
||||
});
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 3000);
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
|
||||
updateConfigFormValue(state, ["gateway", "port"], 18789);
|
||||
|
||||
expect(state.configFormDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stageConfigPreset", () => {
|
||||
it("ignores preset staging before a config snapshot is ready", () => {
|
||||
const state = createState();
|
||||
|
||||
stageConfigPreset(state, {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.configForm).toBeNull();
|
||||
expect(state.configRaw).toBe("");
|
||||
expect(state.configFormDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("stages preset changes without dropping unrelated config", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 12_000,
|
||||
bootstrapTotalMaxChars: 60_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
gateway: { mode: "local" },
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{\n "agents": {\n "defaults": {\n "bootstrapMaxChars": 12000,\n "bootstrapTotalMaxChars": 60000,\n "contextInjection": "always"\n }\n },\n "gateway": {\n "mode": "local"\n }\n}\n',
|
||||
});
|
||||
|
||||
stageConfigPreset(state, {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
expect(state.configForm).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
gateway: { mode: "local" },
|
||||
});
|
||||
});
|
||||
|
||||
it("stays clean when the staged preset already matches the saved config", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 20_000,
|
||||
bootstrapTotalMaxChars: 150_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: '{\n "agents": {\n "defaults": {\n "bootstrapMaxChars": 20000,\n "bootstrapTotalMaxChars": 150000,\n "contextInjection": "always"\n }\n }\n}\n',
|
||||
});
|
||||
|
||||
stageConfigPreset(state, {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 20_000,
|
||||
bootstrapTotalMaxChars: 150_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.configFormDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetConfigPendingChanges", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyMergePatch } from "../../../../src/config/merge-patch.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts";
|
||||
import type { JsonSchema } from "../views/config-form.shared.ts";
|
||||
@@ -165,6 +166,17 @@ async function submitConfigChange(
|
||||
}
|
||||
}
|
||||
|
||||
function syncConfigDraft(state: ConfigState, nextForm: Record<string, unknown>) {
|
||||
const original = cloneConfigObject(
|
||||
state.configFormOriginal ?? state.configSnapshot?.config ?? {},
|
||||
);
|
||||
const nextRaw = serializeConfigForm(nextForm);
|
||||
const originalRaw = serializeConfigForm(original);
|
||||
state.configForm = nextForm;
|
||||
state.configRaw = nextRaw;
|
||||
state.configFormDirty = nextRaw !== originalRaw;
|
||||
}
|
||||
|
||||
export async function saveConfig(state: ConfigState) {
|
||||
await submitConfigChange(state, "config.set", "configSaving");
|
||||
}
|
||||
@@ -203,11 +215,7 @@ export async function runUpdate(state: ConfigState) {
|
||||
function mutateConfigForm(state: ConfigState, mutate: (draft: Record<string, unknown>) => void) {
|
||||
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
|
||||
mutate(base);
|
||||
state.configForm = base;
|
||||
state.configFormDirty = true;
|
||||
if (state.configFormMode === "form") {
|
||||
state.configRaw = serializeConfigForm(base);
|
||||
}
|
||||
syncConfigDraft(state, base);
|
||||
}
|
||||
|
||||
export function updateConfigFormValue(
|
||||
@@ -218,6 +226,25 @@ export function updateConfigFormValue(
|
||||
mutateConfigForm(state, (draft) => setPathValue(draft, path, value));
|
||||
}
|
||||
|
||||
export function stageConfigPreset(state: ConfigState, patch: Record<string, unknown>) {
|
||||
const snapshotConfig =
|
||||
state.configSnapshot?.config &&
|
||||
typeof state.configSnapshot.config === "object" &&
|
||||
!Array.isArray(state.configSnapshot.config)
|
||||
? state.configSnapshot.config
|
||||
: null;
|
||||
const baseSource = state.configForm ?? snapshotConfig;
|
||||
if (!baseSource || (!state.configForm && !state.configSnapshot?.hash)) {
|
||||
return;
|
||||
}
|
||||
const base = cloneConfigObject(baseSource);
|
||||
const merged = applyMergePatch(base, patch);
|
||||
if (!merged || typeof merged !== "object" || Array.isArray(merged)) {
|
||||
return;
|
||||
}
|
||||
syncConfigDraft(state, cloneConfigObject(merged as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export function resetConfigPendingChanges(state: ConfigState) {
|
||||
state.configForm = cloneConfigObject(
|
||||
state.configFormOriginal ?? state.configSnapshot?.config ?? {},
|
||||
|
||||
@@ -12,6 +12,9 @@ describe("loadControlUiBootstrapConfig", () => {
|
||||
basePath: "/openclaw",
|
||||
assistantName: "Ops",
|
||||
assistantAvatar: "O",
|
||||
assistantAvatarSource: "avatars/ops.png",
|
||||
assistantAvatarStatus: "none",
|
||||
assistantAvatarReason: "missing",
|
||||
assistantAgentId: "main",
|
||||
serverVersion: "2026.3.7",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
@@ -25,6 +28,9 @@ describe("loadControlUiBootstrapConfig", () => {
|
||||
basePath: "/openclaw",
|
||||
assistantName: "Assistant",
|
||||
assistantAvatar: null,
|
||||
assistantAvatarSource: null,
|
||||
assistantAvatarStatus: null,
|
||||
assistantAvatarReason: null,
|
||||
assistantAgentId: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts" as const,
|
||||
@@ -40,6 +46,9 @@ describe("loadControlUiBootstrapConfig", () => {
|
||||
);
|
||||
expect(state.assistantName).toBe("Ops");
|
||||
expect(state.assistantAvatar).toBe("O");
|
||||
expect(state.assistantAvatarSource).toBe("avatars/ops.png");
|
||||
expect(state.assistantAvatarStatus).toBe("none");
|
||||
expect(state.assistantAvatarReason).toBe("missing");
|
||||
expect(state.assistantAgentId).toBe("main");
|
||||
expect(state.serverVersion).toBe("2026.3.7");
|
||||
expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]);
|
||||
|
||||
@@ -11,6 +11,9 @@ export type ControlUiBootstrapState = {
|
||||
basePath: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
assistantAgentId: string | null;
|
||||
serverVersion: string | null;
|
||||
localMediaPreviewRoots: string[];
|
||||
@@ -66,9 +69,15 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat
|
||||
agentId: parsed.assistantAgentId ?? null,
|
||||
name: parsed.assistantName,
|
||||
avatar: parsed.assistantAvatar ?? null,
|
||||
avatarSource: parsed.assistantAvatarSource ?? null,
|
||||
avatarStatus: parsed.assistantAvatarStatus ?? null,
|
||||
avatarReason: parsed.assistantAvatarReason ?? null,
|
||||
});
|
||||
state.assistantName = normalized.name;
|
||||
state.assistantAvatar = normalized.avatar;
|
||||
state.assistantAvatarSource = normalized.avatarSource ?? null;
|
||||
state.assistantAvatarStatus = normalized.avatarStatus ?? null;
|
||||
state.assistantAvatarReason = normalized.avatarReason ?? null;
|
||||
state.assistantAgentId = normalized.agentId ?? null;
|
||||
state.serverVersion = parsed.serverVersion ?? null;
|
||||
state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots)
|
||||
|
||||
@@ -344,6 +344,9 @@ export type AgentIdentityResult = {
|
||||
agentId: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
avatarSource?: string | null;
|
||||
avatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
avatarReason?: string | null;
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
agentLogoUrl,
|
||||
assistantAvatarFallbackUrl,
|
||||
buildAgentContext,
|
||||
resolveConfiguredCronModelSuggestions,
|
||||
resolveAgentAvatarUrl,
|
||||
@@ -114,6 +115,13 @@ describe("agentLogoUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistantAvatarFallbackUrl", () => {
|
||||
it("uses the bundled Molty png for assistant profile fallbacks", () => {
|
||||
expect(assistantAvatarFallbackUrl("/ui")).toBe("/ui/apple-touch-icon.png");
|
||||
expect(assistantAvatarFallbackUrl("")).toBe("apple-touch-icon.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentAvatarUrl", () => {
|
||||
it("prefers a runtime avatar URL over non-URL identity avatars", () => {
|
||||
expect(
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeToolName,
|
||||
resolveToolProfilePolicy,
|
||||
} from "../../../../src/agents/tool-policy-shared.js";
|
||||
import { DEFAULT_ASSISTANT_AVATAR } from "../assistant-identity.ts";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
|
||||
import type {
|
||||
AgentIdentityResult,
|
||||
@@ -245,6 +246,37 @@ export function agentLogoUrl(basePath: string): string {
|
||||
return base ? `${base}/favicon.svg` : "favicon.svg";
|
||||
}
|
||||
|
||||
export function assistantAvatarFallbackUrl(basePath: string): string {
|
||||
const base = normalizeOptionalString(basePath)?.replace(/\/$/, "") ?? "";
|
||||
return base ? `${base}/apple-touch-icon.png` : "apple-touch-icon.png";
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
|
||||
}
|
||||
|
||||
const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
|
||||
|
||||
export function resolveAssistantTextAvatar(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) {
|
||||
return null;
|
||||
}
|
||||
if (isAvatarUrl(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
trimmed.length > 8 ||
|
||||
/\s/.test(trimmed) ||
|
||||
/[\\/.:]/.test(trimmed) ||
|
||||
UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isLikelyEmoji(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -192,4 +192,52 @@ describe("renderChat", () => {
|
||||
expect(avatar?.textContent).toContain("VC");
|
||||
expect(avatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("renders configured assistant image avatars in transcript groups", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
assistantName: "Val",
|
||||
assistantAvatar: "avatars/val.png",
|
||||
assistantAvatarUrl: "blob:identity-avatar",
|
||||
messages: [{ role: "assistant", content: "hello", timestamp: 1000 }],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(
|
||||
".chat-group.assistant img.chat-avatar",
|
||||
);
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("blob:identity-avatar");
|
||||
expect(avatar?.getAttribute("alt")).toBe("Val");
|
||||
});
|
||||
|
||||
it("uses the Molty png as the welcome fallback assistant avatar", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
assistantName: "Val",
|
||||
assistantAvatar: null,
|
||||
assistantAvatarUrl: null,
|
||||
messages: [],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(".agent-chat__avatar--logo img");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.getAttribute("src")).toBe("apple-touch-icon.png");
|
||||
expect(avatar?.getAttribute("alt")).toBe("Val");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,11 @@ import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
|
||||
import { resolveLocalUserName } from "../user-identity.ts";
|
||||
import { agentLogoUrl, resolveChatAvatarRenderUrl } from "./agents-utils.ts";
|
||||
import {
|
||||
agentLogoUrl,
|
||||
assistantAvatarFallbackUrl,
|
||||
resolveChatAvatarRenderUrl,
|
||||
} from "./agents-utils.ts";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
||||
import "../components/resizable-divider.ts";
|
||||
|
||||
@@ -484,6 +488,7 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
const name = props.assistantName || "Assistant";
|
||||
const avatar = resolveAssistantAvatarUrl(props);
|
||||
const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
const fallbackAvatarUrl = assistantAvatarFallbackUrl(props.basePath ?? "");
|
||||
const logoUrl = agentLogoUrl(props.basePath ?? "");
|
||||
|
||||
return html`
|
||||
@@ -500,7 +505,7 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
${avatarText}
|
||||
</div>`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
|
||||
<img src=${logoUrl} alt="OpenClaw" />
|
||||
<img src=${fallbackAvatarUrl} alt=${name} />
|
||||
</div>`}
|
||||
<h2>${name}</h2>
|
||||
<div class="agent-chat__badges">
|
||||
|
||||
50
ui/src/ui/views/config-presets.test.ts
Normal file
50
ui/src/ui/views/config-presets.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OpenClawSchema } from "../../../../src/config/zod-schema.js";
|
||||
import { CONFIG_PRESETS, detectActivePreset } from "./config-presets.ts";
|
||||
|
||||
describe("detectActivePreset", () => {
|
||||
it("keeps every preset patch valid for the runtime config schema", () => {
|
||||
for (const preset of CONFIG_PRESETS) {
|
||||
const defaults = preset.patch.agents.defaults;
|
||||
|
||||
expect(() => OpenClawSchema.parse(preset.patch), preset.id).not.toThrow();
|
||||
expect(defaults.bootstrapMaxChars, preset.id).toBeGreaterThan(0);
|
||||
expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThan(0);
|
||||
expect(defaults.bootstrapTotalMaxChars, preset.id).toBeGreaterThanOrEqual(
|
||||
defaults.bootstrapMaxChars,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when bootstrap defaults are unset", () => {
|
||||
expect(detectActivePreset({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the matching preset when all preset fields match", () => {
|
||||
expect(
|
||||
detectActivePreset({
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "always",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("codeAgent");
|
||||
});
|
||||
|
||||
it("does not match a preset when context injection differs", () => {
|
||||
expect(
|
||||
detectActivePreset({
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: 50_000,
|
||||
bootstrapTotalMaxChars: 300_000,
|
||||
contextInjection: "continuation-skip",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,33 @@
|
||||
|
||||
export type ConfigPresetId = "personal" | "codeAgent" | "teamBot" | "minimal";
|
||||
|
||||
export type ConfigPresetPatch = {
|
||||
agents: {
|
||||
defaults: {
|
||||
bootstrapMaxChars: number;
|
||||
bootstrapTotalMaxChars: number;
|
||||
contextInjection: "always" | "continuation-skip";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ConfigPreset = {
|
||||
id: ConfigPresetId;
|
||||
label: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
impact: string;
|
||||
icon: string;
|
||||
patch: Record<string, unknown>;
|
||||
patch: ConfigPresetPatch;
|
||||
};
|
||||
|
||||
export const CONFIG_PRESETS: ConfigPreset[] = [
|
||||
{
|
||||
id: "personal",
|
||||
label: "Personal Assistant",
|
||||
description: "Balanced context and cost. Best for daily use.",
|
||||
description: "Balanced default for daily use.",
|
||||
detail: "Good fit for chat, docs, and light edits without a large coding budget.",
|
||||
impact: "Injects bootstrap context every turn with a moderate prompt budget.",
|
||||
icon: "✨",
|
||||
patch: {
|
||||
agents: {
|
||||
@@ -32,7 +46,9 @@ export const CONFIG_PRESETS: ConfigPreset[] = [
|
||||
{
|
||||
id: "codeAgent",
|
||||
label: "Code Agent",
|
||||
description: "Higher context for coding tasks. More tokens per turn.",
|
||||
description: "Highest context budget for repo work.",
|
||||
detail: "Best for multi-file changes, long bootstrap docs, and code-heavy sessions.",
|
||||
impact: "Uses the largest prompt budget and reinjects context every turn.",
|
||||
icon: "🛠️",
|
||||
patch: {
|
||||
agents: {
|
||||
@@ -47,7 +63,10 @@ export const CONFIG_PRESETS: ConfigPreset[] = [
|
||||
{
|
||||
id: "teamBot",
|
||||
label: "Team Bot",
|
||||
description: "Multi-channel, group-aware. Leaner per-turn context.",
|
||||
description: "Lean follow-ups for shared bots.",
|
||||
detail:
|
||||
"Best for multi-channel workflows where continuity matters more than large bootstrap payloads.",
|
||||
impact: "Keeps follow-up turns smaller by skipping safe continuation reinjection.",
|
||||
icon: "👥",
|
||||
patch: {
|
||||
agents: {
|
||||
@@ -62,7 +81,9 @@ export const CONFIG_PRESETS: ConfigPreset[] = [
|
||||
{
|
||||
id: "minimal",
|
||||
label: "Minimal",
|
||||
description: "Lowest cost per turn. Fast and lean.",
|
||||
description: "Smallest context budget and lowest cost.",
|
||||
detail: "Best for quick utility turns, automations, and cost-sensitive workflows.",
|
||||
impact: "Uses the smallest bootstrap budget and the leanest follow-up behavior.",
|
||||
icon: "⚡",
|
||||
patch: {
|
||||
agents: {
|
||||
@@ -87,10 +108,11 @@ export function detectActivePreset(config: Record<string, unknown>): ConfigPrese
|
||||
const agents = config.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
||||
if (!defaults) {
|
||||
return "personal"; // treat unset as default
|
||||
return null;
|
||||
}
|
||||
const maxChars = defaults.bootstrapMaxChars;
|
||||
const totalMax = defaults.bootstrapTotalMaxChars;
|
||||
const contextInjection = defaults.contextInjection;
|
||||
for (const preset of CONFIG_PRESETS) {
|
||||
const presetDefaults = (preset.patch.agents as Record<string, unknown>)?.defaults as
|
||||
| Record<string, unknown>
|
||||
@@ -100,7 +122,8 @@ export function detectActivePreset(config: Record<string, unknown>): ConfigPrese
|
||||
}
|
||||
if (
|
||||
maxChars === presetDefaults.bootstrapMaxChars &&
|
||||
totalMax === presetDefaults.bootstrapTotalMaxChars
|
||||
totalMax === presetDefaults.bootstrapTotalMaxChars &&
|
||||
contextInjection === presetDefaults.contextInjection
|
||||
) {
|
||||
return preset.id;
|
||||
}
|
||||
|
||||
@@ -37,16 +37,25 @@ function createProps(overrides: Partial<QuickSettingsProps> = {}): QuickSettings
|
||||
onOpenCustomThemeImport: vi.fn(),
|
||||
setThemeMode: vi.fn(),
|
||||
setBorderRadius: vi.fn(),
|
||||
userName: "Val",
|
||||
userAvatar: null,
|
||||
onUserNameChange: vi.fn(),
|
||||
onUserAvatarChange: vi.fn(),
|
||||
configObject: {},
|
||||
onApplyPreset: vi.fn(),
|
||||
onSelectPreset: vi.fn(),
|
||||
onAdvancedSettings: vi.fn(),
|
||||
connected: true,
|
||||
gatewayUrl: "ws://localhost:18789",
|
||||
assistantName: "OpenClaw",
|
||||
assistantAvatar: null,
|
||||
assistantAvatarUrl: null,
|
||||
assistantAvatarSource: null,
|
||||
assistantAvatarStatus: null,
|
||||
assistantAvatarReason: null,
|
||||
assistantAvatarOverride: null,
|
||||
assistantAvatarUploadBusy: false,
|
||||
assistantAvatarUploadError: null,
|
||||
onAssistantAvatarOverrideChange: vi.fn(),
|
||||
onAssistantAvatarClearOverride: vi.fn(),
|
||||
basePath: "",
|
||||
version: "2026.4.22",
|
||||
...overrides,
|
||||
};
|
||||
@@ -58,10 +67,186 @@ describe("renderQuickSettings", () => {
|
||||
|
||||
render(renderQuickSettings(createProps()), container);
|
||||
|
||||
expect(container.querySelectorAll(".qs-stack")).toHaveLength(4);
|
||||
expect(container.querySelectorAll(".qs-stack")).toHaveLength(3);
|
||||
expect(container.querySelector(".qs-card--personal")).not.toBeNull();
|
||||
expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the local user name fixed and shows the assistant identity", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
assistantName: "Nova",
|
||||
assistantAvatar: "assets/avatars/nova-portrait.png",
|
||||
assistantAvatarUrl: "blob:nova",
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const titles = Array.from(container.querySelectorAll(".qs-identity-card__title")).map((node) =>
|
||||
node.textContent?.trim(),
|
||||
);
|
||||
expect(titles).toContain("You");
|
||||
expect(titles).toContain("Nova");
|
||||
expect(container.querySelector('input[placeholder="You"]')).toBeNull();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".qs-row__label")).some(
|
||||
(node) => node.textContent?.trim() === "Name",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe("blob:nova");
|
||||
});
|
||||
|
||||
it("renders same-origin assistant avatar routes from IDENTITY.md", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
assistantName: "Nova",
|
||||
assistantAvatar: "/avatar/main",
|
||||
assistantAvatarUrl: "/avatar/main",
|
||||
assistantAvatarSource: "assets/avatars/nova-portrait.png",
|
||||
assistantAvatarStatus: "local",
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe(
|
||||
"/avatar/main",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the IDENTITY.md avatar source when the assistant falls back to the logo", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
assistantName: "Nova",
|
||||
assistantAvatar: "/avatar/main",
|
||||
assistantAvatarUrl: null,
|
||||
assistantAvatarSource: "assets/avatars/nova-portrait.png",
|
||||
assistantAvatarStatus: "none",
|
||||
assistantAvatarReason: "missing",
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe(
|
||||
"apple-touch-icon.png",
|
||||
);
|
||||
expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain(
|
||||
"assets/avatars/nova-portrait.png",
|
||||
);
|
||||
expect(container.querySelector(".qs-identity-card__issue")?.textContent?.trim()).toBe(
|
||||
"File not found",
|
||||
);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("label.btn")).some(
|
||||
(label) => label.textContent?.trim() === "Choose image",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reads assistant image imports into an override", () => {
|
||||
const onAssistantAvatarOverrideChange = vi.fn();
|
||||
const readAsDataURL = vi.fn(function (this: FileReader) {
|
||||
Object.defineProperty(this, "result", {
|
||||
configurable: true,
|
||||
value: "data:image/png;base64,YXZhdGFy",
|
||||
});
|
||||
this.dispatchEvent(new Event("load"));
|
||||
});
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
listeners = new Map<string, Array<(event: Event) => void>>();
|
||||
addEventListener(type: string, listener: (event: Event) => void) {
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||
}
|
||||
dispatchEvent(event: Event) {
|
||||
for (const listener of this.listeners.get(event.type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
readAsDataURL = readAsDataURL;
|
||||
}
|
||||
vi.stubGlobal("FileReader", MockFileReader);
|
||||
|
||||
try {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
assistantAvatarSource: "assets/avatars/nova-portrait.png",
|
||||
assistantAvatarStatus: "none",
|
||||
assistantAvatarReason: "missing",
|
||||
onAssistantAvatarOverrideChange,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const inputs = Array.from(container.querySelectorAll('input[type="file"]'));
|
||||
const input = inputs.find((node) =>
|
||||
node.closest(".qs-identity-card--assistant"),
|
||||
) as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.defineProperty(input, "files", {
|
||||
configurable: true,
|
||||
value: [new File(["avatar"], "avatar.png", { type: "image/png" })],
|
||||
});
|
||||
input.dispatchEvent(new Event("change"));
|
||||
|
||||
expect(readAsDataURL).toHaveBeenCalledTimes(1);
|
||||
expect(onAssistantAvatarOverrideChange).toHaveBeenCalledWith(
|
||||
"data:image/png;base64,YXZhdGFy",
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("can clear an assistant avatar override back to IDENTITY.md", () => {
|
||||
const onAssistantAvatarClearOverride = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
assistantAvatar: "data:image/png;base64,b3ZlcnJpZGU=",
|
||||
assistantAvatarUrl: "data:image/png;base64,b3ZlcnJpZGU=",
|
||||
assistantAvatarSource: "data:image/png;base64,...",
|
||||
assistantAvatarStatus: "data",
|
||||
assistantAvatarOverride: "data:image/png;base64,b3ZlcnJpZGU=",
|
||||
onAssistantAvatarClearOverride,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain(
|
||||
"UI override",
|
||||
);
|
||||
const clear = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Clear override",
|
||||
);
|
||||
expect(clear).not.toBeUndefined();
|
||||
clear?.dispatchEvent(new Event("click"));
|
||||
|
||||
expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects oversized avatar uploads before reading them", () => {
|
||||
const onUserAvatarChange = vi.fn();
|
||||
const fileReader = vi.fn();
|
||||
@@ -71,7 +256,9 @@ describe("renderQuickSettings", () => {
|
||||
const container = document.createElement("div");
|
||||
render(renderQuickSettings(createProps({ onUserAvatarChange })), container);
|
||||
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
const input = Array.from(container.querySelectorAll('input[type="file"]')).find(
|
||||
(node) => !node.closest(".qs-identity-card--assistant"),
|
||||
) as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
if (!input) {
|
||||
return;
|
||||
|
||||
@@ -8,16 +8,25 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { BorderRadiusStop } from "../storage.ts";
|
||||
import { normalizeOptionalString } from "../string-coerce.ts";
|
||||
import type { ThemeTransitionContext } from "../theme-transition.ts";
|
||||
import type { ThemeMode, ThemeName } from "../theme.ts";
|
||||
import {
|
||||
hasLocalUserIdentity,
|
||||
normalizeLocalUserIdentity,
|
||||
resolveLocalUserAvatarText,
|
||||
resolveLocalUserAvatarUrl,
|
||||
resolveLocalUserName,
|
||||
} from "../user-identity.ts";
|
||||
import { CONFIG_PRESETS, detectActivePreset, type ConfigPresetId } from "./config-presets.ts";
|
||||
import {
|
||||
assistantAvatarFallbackUrl,
|
||||
resolveChatAvatarRenderUrl,
|
||||
resolveAssistantTextAvatar,
|
||||
} from "./agents-utils.ts";
|
||||
import {
|
||||
CONFIG_PRESETS,
|
||||
detectActivePreset,
|
||||
getPresetById,
|
||||
type ConfigPresetId,
|
||||
} from "./config-presets.ts";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -73,14 +82,20 @@ export type QuickSettingsProps = {
|
||||
onOpenCustomThemeImport?: () => void;
|
||||
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
setBorderRadius: (value: number) => void;
|
||||
userName?: string | null;
|
||||
userAvatar?: string | null;
|
||||
onUserNameChange?: (next: string) => void;
|
||||
onUserAvatarChange?: (next: string | null) => void;
|
||||
|
||||
// Presets
|
||||
configObject?: Record<string, unknown>;
|
||||
onApplyPreset?: (presetId: ConfigPresetId) => void;
|
||||
savedConfigObject?: Record<string, unknown>;
|
||||
configDirty?: boolean;
|
||||
configSaving?: boolean;
|
||||
configApplying?: boolean;
|
||||
configReady?: boolean;
|
||||
onSelectPreset?: (presetId: ConfigPresetId) => void;
|
||||
onResetConfig?: () => void;
|
||||
onSaveConfig?: () => void;
|
||||
onApplyConfig?: () => void;
|
||||
|
||||
// Navigation
|
||||
onAdvancedSettings?: () => void;
|
||||
@@ -89,6 +104,17 @@ export type QuickSettingsProps = {
|
||||
connected: boolean;
|
||||
gatewayUrl: string;
|
||||
assistantName: string;
|
||||
assistantAvatar?: string | null;
|
||||
assistantAvatarUrl?: string | null;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
assistantAvatarOverride?: string | null;
|
||||
assistantAvatarUploadBusy?: boolean;
|
||||
assistantAvatarUploadError?: string | null;
|
||||
onAssistantAvatarOverrideChange?: (dataUrl: string) => void | Promise<void>;
|
||||
onAssistantAvatarClearOverride?: () => void | Promise<void>;
|
||||
basePath?: string | null;
|
||||
version: string;
|
||||
};
|
||||
|
||||
@@ -110,9 +136,11 @@ const BORDER_RADIUS_STOPS: Array<{ value: BorderRadiusStop; label: string }> = [
|
||||
];
|
||||
|
||||
const THINKING_LEVELS = ["off", "low", "medium", "high"];
|
||||
const LOCAL_USER_LABEL = "You";
|
||||
// Keep raw uploads comfortably below the 2 MB persisted data URL limit after
|
||||
// base64 expansion and a small MIME/header prefix are added.
|
||||
const MAX_LOCAL_USER_AVATAR_FILE_BYTES = 1_500_000;
|
||||
const MAX_ASSISTANT_AVATAR_UPLOAD_BYTES = MAX_LOCAL_USER_AVATAR_FILE_BYTES;
|
||||
|
||||
function renderDefaultUserAvatar() {
|
||||
return html`
|
||||
@@ -123,29 +151,96 @@ function renderDefaultUserAvatar() {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLocalUserAvatarPreview(
|
||||
name: string | null | undefined,
|
||||
avatar: string | null | undefined,
|
||||
) {
|
||||
const identity = normalizeLocalUserIdentity({ name, avatar });
|
||||
const label = resolveLocalUserName(identity);
|
||||
function renderLocalUserAvatarPreview(avatar: string | null | undefined) {
|
||||
const identity = normalizeLocalUserIdentity({ name: null, avatar });
|
||||
const avatarUrl = resolveLocalUserAvatarUrl(identity);
|
||||
const avatarText = resolveLocalUserAvatarText(identity);
|
||||
if (avatarUrl) {
|
||||
return html`<img class="qs-user-avatar" src=${avatarUrl} alt=${label} />`;
|
||||
return html`<img class="qs-user-avatar" src=${avatarUrl} alt=${LOCAL_USER_LABEL} />`;
|
||||
}
|
||||
if (avatarText) {
|
||||
return html`<div class="qs-user-avatar qs-user-avatar--text" aria-label=${label}>
|
||||
return html`<div class="qs-user-avatar qs-user-avatar--text" aria-label=${LOCAL_USER_LABEL}>
|
||||
${avatarText}
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<div class="qs-user-avatar qs-user-avatar--default" aria-label=${label}>
|
||||
<div class="qs-user-avatar qs-user-avatar--default" aria-label=${LOCAL_USER_LABEL}>
|
||||
${renderDefaultUserAvatar()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveAssistantPreviewAvatarUrl(props: QuickSettingsProps): string | null {
|
||||
if (props.assistantAvatarStatus === "none" && props.assistantAvatarReason === "missing") {
|
||||
return null;
|
||||
}
|
||||
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
|
||||
identity: {
|
||||
avatar: props.assistantAvatar ?? undefined,
|
||||
avatarUrl: props.assistantAvatarUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function formatAssistantAvatarSource(value: string | null | undefined): string | null {
|
||||
const source = normalizeOptionalString(value);
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (/^data:image\//i.test(source)) {
|
||||
const header = source.slice(0, source.indexOf(",") > 0 ? source.indexOf(",") : 32);
|
||||
return `${header},...`;
|
||||
}
|
||||
return source.length > 72 ? `${source.slice(0, 34)}...${source.slice(-24)}` : source;
|
||||
}
|
||||
|
||||
function formatAssistantAvatarIssue(
|
||||
status: QuickSettingsProps["assistantAvatarStatus"],
|
||||
reason: string | null | undefined,
|
||||
_rendered: boolean,
|
||||
): string | null {
|
||||
if (status === "remote") {
|
||||
return "Remote URLs are blocked by Control UI image policy";
|
||||
}
|
||||
if (reason === "missing") {
|
||||
return "File not found";
|
||||
}
|
||||
if (reason === "unsupported_extension") {
|
||||
return "Unsupported image type";
|
||||
}
|
||||
if (reason === "outside_workspace") {
|
||||
return "Outside workspace";
|
||||
}
|
||||
if (reason === "too_large") {
|
||||
return "Image is too large";
|
||||
}
|
||||
return reason ? "Cannot render avatar" : null;
|
||||
}
|
||||
|
||||
function renderAssistantAvatarPreview(props: QuickSettingsProps) {
|
||||
const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant";
|
||||
const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props);
|
||||
if (assistantAvatarUrl) {
|
||||
return html`<img class="qs-assistant-avatar" src=${assistantAvatarUrl} alt=${assistantName} />`;
|
||||
}
|
||||
const assistantAvatarText = resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
if (assistantAvatarText) {
|
||||
return html`<div
|
||||
class="qs-assistant-avatar qs-assistant-avatar--text"
|
||||
aria-label=${assistantName}
|
||||
>
|
||||
${assistantAvatarText}
|
||||
</div>`;
|
||||
}
|
||||
return html`
|
||||
<img
|
||||
class="qs-assistant-avatar qs-assistant-avatar--fallback"
|
||||
src=${assistantAvatarFallbackUrl(props.basePath ?? "")}
|
||||
alt=${assistantName}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
function handleLocalUserAvatarFileSelect(e: Event, props: QuickSettingsProps) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -170,6 +265,101 @@ function handleLocalUserAvatarFileSelect(e: Event, props: QuickSettingsProps) {
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function handleAssistantAvatarFileSelect(e: Event, props: QuickSettingsProps) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
const onAssistantAvatarOverrideChange = props.onAssistantAvatarOverrideChange;
|
||||
if (!file || !onAssistantAvatarOverrideChange) {
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_ASSISTANT_AVATAR_UPLOAD_BYTES) {
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
const result = typeof reader.result === "string" ? reader.result : "";
|
||||
if (result) {
|
||||
void onAssistantAvatarOverrideChange(result);
|
||||
}
|
||||
});
|
||||
reader.readAsDataURL(file);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
type ProfileSettings = {
|
||||
bootstrapMaxChars: number;
|
||||
bootstrapTotalMaxChars: number;
|
||||
contextInjection: "always" | "continuation-skip";
|
||||
};
|
||||
|
||||
const DEFAULT_PROFILE_SETTINGS: ProfileSettings = {
|
||||
bootstrapMaxChars: 12_000,
|
||||
bootstrapTotalMaxChars: 60_000,
|
||||
contextInjection: "always",
|
||||
};
|
||||
|
||||
function resolveProfileSettings(config?: Record<string, unknown>): ProfileSettings {
|
||||
const agents = config?.agents as Record<string, unknown> | undefined;
|
||||
const defaults = agents?.defaults as Record<string, unknown> | undefined;
|
||||
const bootstrapMaxChars =
|
||||
typeof defaults?.bootstrapMaxChars === "number" && Number.isFinite(defaults.bootstrapMaxChars)
|
||||
? Math.floor(defaults.bootstrapMaxChars)
|
||||
: DEFAULT_PROFILE_SETTINGS.bootstrapMaxChars;
|
||||
const bootstrapTotalMaxChars =
|
||||
typeof defaults?.bootstrapTotalMaxChars === "number" &&
|
||||
Number.isFinite(defaults.bootstrapTotalMaxChars)
|
||||
? Math.floor(defaults.bootstrapTotalMaxChars)
|
||||
: DEFAULT_PROFILE_SETTINGS.bootstrapTotalMaxChars;
|
||||
const contextInjection =
|
||||
defaults?.contextInjection === "continuation-skip" ? "continuation-skip" : "always";
|
||||
return { bootstrapMaxChars, bootstrapTotalMaxChars, contextInjection };
|
||||
}
|
||||
|
||||
function profileSettingsEqual(a: ProfileSettings, b: ProfileSettings): boolean {
|
||||
return (
|
||||
a.bootstrapMaxChars === b.bootstrapMaxChars &&
|
||||
a.bootstrapTotalMaxChars === b.bootstrapTotalMaxChars &&
|
||||
a.contextInjection === b.contextInjection
|
||||
);
|
||||
}
|
||||
|
||||
function formatCharBudget(value: number): string {
|
||||
return `${value.toLocaleString()} chars`;
|
||||
}
|
||||
|
||||
function formatContextInjectionLabel(mode: ProfileSettings["contextInjection"]): string {
|
||||
return mode === "always" ? "Every turn" : "Skip safe follow-ups";
|
||||
}
|
||||
|
||||
function describeContextInjection(mode: ProfileSettings["contextInjection"]): string {
|
||||
return mode === "always"
|
||||
? "Reinject workspace bootstrap context on every turn."
|
||||
: "Skip bootstrap reinjection after a completed safe follow-up.";
|
||||
}
|
||||
|
||||
function renderProfileStat(params: {
|
||||
label: string;
|
||||
value: string;
|
||||
previousValue: string;
|
||||
note: string;
|
||||
}) {
|
||||
const changed = params.value !== params.previousValue;
|
||||
return html`
|
||||
<div class="qs-profile-stat ${changed ? "qs-profile-stat--changed" : ""}">
|
||||
<div class="qs-profile-stat__header">
|
||||
<span class="qs-profile-stat__label">${params.label}</span>
|
||||
<span class="qs-profile-stat__value">${params.value}</span>
|
||||
</div>
|
||||
<div class="qs-profile-stat__sub">
|
||||
${changed ? `Was ${params.previousValue}` : "Matches current default"}
|
||||
</div>
|
||||
<div class="qs-profile-stat__note muted">${params.note}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Card renderers ──
|
||||
|
||||
function renderCardHeader(icon: TemplateResult, title: string, action?: TemplateResult) {
|
||||
@@ -413,34 +603,111 @@ function renderAppearanceCard(props: QuickSettingsProps) {
|
||||
|
||||
function renderPersonalCard(props: QuickSettingsProps) {
|
||||
const identity = normalizeLocalUserIdentity({
|
||||
name: props.userName ?? null,
|
||||
name: null,
|
||||
avatar: props.userAvatar ?? null,
|
||||
});
|
||||
const avatarText = resolveLocalUserAvatarText(identity) ?? "";
|
||||
const label = resolveLocalUserName(identity);
|
||||
const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant";
|
||||
const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props);
|
||||
const assistantAvatarRendered = Boolean(
|
||||
assistantAvatarUrl || resolveAssistantTextAvatar(props.assistantAvatar),
|
||||
);
|
||||
const assistantAvatarSource = formatAssistantAvatarSource(props.assistantAvatarSource);
|
||||
const assistantAvatarIssue = formatAssistantAvatarIssue(
|
||||
props.assistantAvatarStatus ?? null,
|
||||
props.assistantAvatarReason,
|
||||
assistantAvatarRendered,
|
||||
);
|
||||
const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride);
|
||||
const assistantAvatarSourceLabel = assistantAvatarOverride ? "UI override" : "IDENTITY.md";
|
||||
const canOverrideAssistantAvatar = Boolean(props.onAssistantAvatarOverrideChange);
|
||||
const assistantAvatarSubtitle = assistantAvatarOverride
|
||||
? "Override from settings"
|
||||
: assistantAvatarIssue
|
||||
? "Fallback avatar"
|
||||
: assistantAvatarRendered
|
||||
? "From IDENTITY.md"
|
||||
: "Fallback logo";
|
||||
return html`
|
||||
<div class="qs-card">
|
||||
<div class="qs-card qs-card--personal">
|
||||
${renderCardHeader(icons.image, "Personal")}
|
||||
<div class="qs-card__body">
|
||||
<div class="qs-personal-preview">
|
||||
${renderLocalUserAvatarPreview(props.userName, props.userAvatar)}
|
||||
<div class="qs-personal-preview__copy">
|
||||
<div class="qs-personal-preview__title">${label}</div>
|
||||
<div class="muted">This browser only</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<label class="qs-field">
|
||||
<span class="qs-row__label">Name</span>
|
||||
<input
|
||||
class="qs-field__input"
|
||||
type="text"
|
||||
maxlength="50"
|
||||
.value=${props.userName ?? ""}
|
||||
placeholder="You"
|
||||
@input=${(e: Event) => props.onUserNameChange?.((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div class="qs-identity-grid">
|
||||
<section class="qs-identity-card" aria-label="Your local chat identity">
|
||||
${renderLocalUserAvatarPreview(props.userAvatar)}
|
||||
<div class="qs-identity-card__copy">
|
||||
<div class="qs-identity-card__eyebrow">User</div>
|
||||
<div class="qs-identity-card__title">${LOCAL_USER_LABEL}</div>
|
||||
<div class="qs-identity-card__sub">Avatar is browser-local</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="qs-identity-card qs-identity-card--assistant"
|
||||
aria-label="Assistant identity"
|
||||
>
|
||||
${renderAssistantAvatarPreview(props)}
|
||||
<div class="qs-identity-card__copy">
|
||||
<div class="qs-identity-card__eyebrow">Assistant</div>
|
||||
<div class="qs-identity-card__title">${assistantName}</div>
|
||||
<div class="qs-identity-card__sub">${assistantAvatarSubtitle}</div>
|
||||
${assistantAvatarSource
|
||||
? html`
|
||||
<div
|
||||
class="qs-identity-card__source"
|
||||
title=${props.assistantAvatarSource ?? ""}
|
||||
>
|
||||
<span>${assistantAvatarSourceLabel}</span>
|
||||
<code>${assistantAvatarSource}</code>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${assistantAvatarIssue
|
||||
? html`<div class="qs-identity-card__issue">${assistantAvatarIssue}</div>`
|
||||
: nothing}
|
||||
${canOverrideAssistantAvatar
|
||||
? html`
|
||||
<div class="qs-identity-card__repair">
|
||||
<label class="btn btn--sm">
|
||||
${props.assistantAvatarUploadBusy
|
||||
? "Saving..."
|
||||
: assistantAvatarOverride
|
||||
? "Replace image"
|
||||
: "Choose image"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
?disabled=${props.assistantAvatarUploadBusy === true}
|
||||
@change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)}
|
||||
/>
|
||||
</label>
|
||||
${assistantAvatarOverride
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--ghost"
|
||||
?disabled=${props.assistantAvatarUploadBusy === true}
|
||||
@click=${() => {
|
||||
void props.onAssistantAvatarClearOverride?.();
|
||||
}}
|
||||
>
|
||||
Clear override
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<div class="muted">
|
||||
Stores a Control UI override. Clear it to return to IDENTITY.md.
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${props.assistantAvatarUploadError
|
||||
? html`<div class="qs-identity-card__error">
|
||||
${props.assistantAvatarUploadError}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="qs-row">
|
||||
<label class="qs-field">
|
||||
@@ -471,13 +738,12 @@ function renderPersonalCard(props: QuickSettingsProps) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--ghost"
|
||||
?disabled=${!hasLocalUserIdentity(identity)}
|
||||
?disabled=${!identity.avatar}
|
||||
@click=${() => {
|
||||
props.onUserNameChange?.("");
|
||||
props.onUserAvatarChange?.(null);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
Clear avatar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,24 +752,209 @@ function renderPersonalCard(props: QuickSettingsProps) {
|
||||
}
|
||||
|
||||
function renderPresetsCard(props: QuickSettingsProps) {
|
||||
const activePreset = props.configObject ? detectActivePreset(props.configObject) : "personal";
|
||||
const draftConfig = props.configObject ?? props.savedConfigObject ?? {};
|
||||
const savedConfig = props.savedConfigObject ?? {};
|
||||
const selectedPresetId = detectActivePreset(draftConfig);
|
||||
const savedPresetId = detectActivePreset(savedConfig);
|
||||
const selectedPreset = selectedPresetId ? getPresetById(selectedPresetId) : undefined;
|
||||
const savedPreset = savedPresetId ? getPresetById(savedPresetId) : undefined;
|
||||
const draftSettings = resolveProfileSettings(draftConfig);
|
||||
const savedSettings = resolveProfileSettings(savedConfig);
|
||||
const hasPendingProfileChange = !profileSettingsEqual(draftSettings, savedSettings);
|
||||
const hasPendingConfigChange = props.configDirty === true;
|
||||
const canCommit =
|
||||
props.connected &&
|
||||
props.configReady === true &&
|
||||
props.configSaving !== true &&
|
||||
props.configApplying !== true;
|
||||
const stateBanner = hasPendingProfileChange
|
||||
? html`
|
||||
<div class="qs-profile-state qs-profile-state--pending" aria-live="polite">
|
||||
<span class="qs-status-dot"></span>
|
||||
<div class="qs-profile-state__text">
|
||||
<span class="qs-profile-state__title"
|
||||
>${selectedPreset?.label ?? "Custom"} is selected but not saved yet.</span
|
||||
>
|
||||
<span class="qs-profile-state__copy"
|
||||
>Save Profile writes it as the default. Apply Now writes it and reloads the current
|
||||
session.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: savedPreset
|
||||
? html`
|
||||
<div class="qs-profile-state qs-profile-state--ok" aria-live="polite">
|
||||
<span class="qs-status-dot qs-status-dot--ok"></span>
|
||||
<div class="qs-profile-state__text">
|
||||
<span class="qs-profile-state__title"
|
||||
>${savedPreset.label} is your current default.</span
|
||||
>
|
||||
<span class="qs-profile-state__copy"
|
||||
>Profiles only change bootstrap size and follow-up reinjection behavior.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="qs-profile-state" aria-live="polite">
|
||||
<span class="qs-status-dot"></span>
|
||||
<div class="qs-profile-state__text">
|
||||
<span class="qs-profile-state__title">Custom bootstrap settings are active.</span>
|
||||
<span class="qs-profile-state__copy"
|
||||
>Choose a built-in profile to replace the current custom values.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const panelTitle = selectedPreset?.label ?? "Custom Configuration";
|
||||
const panelDescription =
|
||||
selectedPreset?.detail ?? "This config does not currently match one of the built-in profiles.";
|
||||
const panelImpact =
|
||||
selectedPreset?.impact ??
|
||||
"Pick a profile to stage a focused change to bootstrap size and follow-up behavior.";
|
||||
const commitCopy = hasPendingProfileChange
|
||||
? "Save Profile writes this as the default. Apply Now writes it and reloads the current session."
|
||||
: "Other staged config edits are pending. Saving here will commit all staged config changes.";
|
||||
|
||||
return html`
|
||||
<div class="qs-card qs-card--span-all">
|
||||
${renderCardHeader(icons.zap, "Profile")}
|
||||
<div class="qs-card__body qs-presets-grid">
|
||||
${CONFIG_PRESETS.map(
|
||||
(preset) => html`
|
||||
<button
|
||||
class="qs-preset ${preset.id === activePreset ? "qs-preset--active" : ""}"
|
||||
@click=${() => props.onApplyPreset?.(preset.id)}
|
||||
>
|
||||
<span class="qs-preset__icon">${preset.icon}</span>
|
||||
<span class="qs-preset__label">${preset.label}</span>
|
||||
<span class="qs-preset__desc muted">${preset.description}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
${renderCardHeader(
|
||||
icons.zap,
|
||||
"Context Profile",
|
||||
hasPendingProfileChange
|
||||
? html`<span class="qs-badge qs-badge--warn">Pending</span>`
|
||||
: savedPreset
|
||||
? html`<span class="qs-badge qs-badge--ok">Saved</span>`
|
||||
: html`<span class="qs-badge">Custom</span>`,
|
||||
)}
|
||||
<div class="qs-card__body qs-profiles">
|
||||
<div class="qs-profiles__copy">
|
||||
<div class="qs-profiles__eyebrow">Bootstrap Context</div>
|
||||
<p class="qs-profiles__intro">
|
||||
Choose how much workspace context OpenClaw injects into each run. These profiles do not
|
||||
change your model, tools, channels, or theme.
|
||||
</p>
|
||||
${stateBanner}
|
||||
<div class="qs-presets-grid">
|
||||
${CONFIG_PRESETS.map((preset) => {
|
||||
const presetDefaults = ((preset.patch.agents as Record<string, unknown> | undefined)
|
||||
?.defaults ?? {}) as Record<string, unknown>;
|
||||
const presetContext =
|
||||
presetDefaults.contextInjection === "continuation-skip"
|
||||
? "continuation-skip"
|
||||
: "always";
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class="qs-preset ${preset.id === selectedPresetId ? "qs-preset--active" : ""}"
|
||||
aria-pressed=${preset.id === selectedPresetId}
|
||||
@click=${() => props.onSelectPreset?.(preset.id)}
|
||||
>
|
||||
<div class="qs-preset__head">
|
||||
<div class="qs-preset__identity">
|
||||
<span class="qs-preset__icon">${preset.icon}</span>
|
||||
<div class="qs-preset__identity-copy">
|
||||
<span class="qs-preset__label">${preset.label}</span>
|
||||
<span class="qs-preset__desc muted">${preset.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-preset__badges">
|
||||
${preset.id === savedPresetId
|
||||
? html`<span class="qs-badge qs-badge--ok">Current</span>`
|
||||
: nothing}
|
||||
${hasPendingProfileChange && preset.id === selectedPresetId
|
||||
? html`<span class="qs-badge qs-badge--warn">Selected</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-preset__meta">
|
||||
<span
|
||||
>${formatCharBudget(Number(presetDefaults.bootstrapMaxChars ?? 0))} per
|
||||
file</span
|
||||
>
|
||||
<span
|
||||
>${formatCharBudget(Number(presetDefaults.bootstrapTotalMaxChars ?? 0))}
|
||||
total</span
|
||||
>
|
||||
<span>${formatContextInjectionLabel(presetContext)}</span>
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="qs-profile-panel">
|
||||
<div class="qs-profile-panel__eyebrow">
|
||||
${selectedPreset ? "Selected Profile" : "Current Values"}
|
||||
</div>
|
||||
<h4 class="qs-profile-panel__title">${panelTitle}</h4>
|
||||
<p class="qs-profile-panel__copy">${panelDescription}</p>
|
||||
<div class="qs-profile-panel__impact">${panelImpact}</div>
|
||||
|
||||
<div class="qs-profile-panel__stats">
|
||||
${renderProfileStat({
|
||||
label: "Bootstrap Per File",
|
||||
value: formatCharBudget(draftSettings.bootstrapMaxChars),
|
||||
previousValue: formatCharBudget(savedSettings.bootstrapMaxChars),
|
||||
note: "Maximum context injected from any single bootstrap file.",
|
||||
})}
|
||||
${renderProfileStat({
|
||||
label: "Bootstrap Total",
|
||||
value: formatCharBudget(draftSettings.bootstrapTotalMaxChars),
|
||||
previousValue: formatCharBudget(savedSettings.bootstrapTotalMaxChars),
|
||||
note: "Total combined context allowed across all bootstrap files.",
|
||||
})}
|
||||
${renderProfileStat({
|
||||
label: "Follow-up Turns",
|
||||
value: formatContextInjectionLabel(draftSettings.contextInjection),
|
||||
previousValue: formatContextInjectionLabel(savedSettings.contextInjection),
|
||||
note: describeContextInjection(draftSettings.contextInjection),
|
||||
})}
|
||||
</div>
|
||||
|
||||
${hasPendingConfigChange
|
||||
? html`
|
||||
<div class="qs-profile-panel__actions">
|
||||
<div class="qs-profile-panel__actions-copy muted">${commitCopy}</div>
|
||||
<div class="qs-profile-panel__actions-row">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${props.configSaving === true || props.configApplying === true}
|
||||
@click=${props.onResetConfig}
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${!canCommit}
|
||||
@click=${props.onSaveConfig}
|
||||
>
|
||||
${props.configSaving === true
|
||||
? "Saving…"
|
||||
: hasPendingProfileChange
|
||||
? "Save Profile"
|
||||
: "Save Changes"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!canCommit}
|
||||
@click=${props.onApplyConfig}
|
||||
>
|
||||
${props.configApplying === true ? "Applying…" : "Apply Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="qs-profile-panel__footer muted" aria-live="polite">
|
||||
${savedPreset
|
||||
? "Saved and ready. Choose another profile to stage a change."
|
||||
: "Current values are custom. Choose a profile to stage a change."}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -526,6 +977,10 @@ function renderStack(...cards: TemplateResult[]) {
|
||||
return html`<div class="qs-stack">${cards}</div>`;
|
||||
}
|
||||
|
||||
function renderWideStack(...cards: TemplateResult[]) {
|
||||
return html`<div class="qs-stack qs-stack--wide">${cards}</div>`;
|
||||
}
|
||||
|
||||
// ── Main render ──
|
||||
|
||||
export function renderQuickSettings(props: QuickSettingsProps) {
|
||||
@@ -540,9 +995,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
|
||||
|
||||
<div class="qs-grid">
|
||||
${renderStack(renderModelCard(props), renderSecurityCard(props))}
|
||||
${renderPersonalCard(props)}
|
||||
${renderStack(renderChannelsCard(props), renderAutomationsCard(props))}
|
||||
${renderStack(renderAppearanceCard(props))} ${renderStack(renderPersonalCard(props))}
|
||||
${renderPresetsCard(props)}
|
||||
${renderWideStack(renderAppearanceCard(props))} ${renderPresetsCard(props)}
|
||||
</div>
|
||||
|
||||
${renderConnectionFooter(props)}
|
||||
|
||||
Reference in New Issue
Block a user