mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:30:57 +00:00
fix(ui): scope agent identity to active session
Co-authored-by: Sahil Satralkar <62758655+sahilsatralkar@users.noreply.github.com>
This commit is contained in:
@@ -257,6 +257,57 @@ describe("resolveAgentAvatar", () => {
|
||||
await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true });
|
||||
});
|
||||
|
||||
it("prefers non-default agent avatar over ui.assistant.avatar with includeUiOverride", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const mainWorkspace = path.join(root, "main");
|
||||
const workerWorkspace = path.join(root, "worker");
|
||||
await writeFile(path.join(mainWorkspace, "ui-avatar.png"));
|
||||
await writeFile(path.join(workerWorkspace, "worker-avatar.png"));
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: { assistant: { avatar: "ui-avatar.png" } },
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspace },
|
||||
{ id: "worker", workspace: workerWorkspace, identity: { avatar: "worker-avatar.png" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const workspaceReal = await fs.realpath(workerWorkspace);
|
||||
const resolved = resolveAgentAvatar(cfg, "worker", { includeUiOverride: true });
|
||||
expect(resolved.kind).toBe("local");
|
||||
if (resolved.kind === "local") {
|
||||
const resolvedReal = await fs.realpath(resolved.filePath);
|
||||
expect(path.relative(workspaceReal, resolvedReal)).toBe("worker-avatar.png");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to ui.assistant.avatar for non-default agents without their own avatar", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const mainWorkspace = path.join(root, "main");
|
||||
const workerWorkspace = path.join(root, "worker");
|
||||
await writeFile(path.join(workerWorkspace, "ui-avatar.png"));
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: { assistant: { avatar: "ui-avatar.png" } },
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspace },
|
||||
{ id: "worker", workspace: workerWorkspace },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const workspaceReal = await fs.realpath(workerWorkspace);
|
||||
const resolved = resolveAgentAvatar(cfg, "worker", { includeUiOverride: true });
|
||||
expect(resolved.kind).toBe("local");
|
||||
if (resolved.kind === "local") {
|
||||
const resolvedReal = await fs.realpath(resolved.filePath);
|
||||
expect(path.relative(workspaceReal, resolvedReal)).toBe("ui-avatar.png");
|
||||
}
|
||||
});
|
||||
|
||||
it("ui.assistant.avatar takes priority over IDENTITY.md avatar with includeUiOverride", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
AVATAR_MAX_BYTES,
|
||||
hasAvatarUriScheme,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from "../shared/avatar-policy.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js";
|
||||
import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
|
||||
import { resolveAgentIdentity } from "./identity.js";
|
||||
|
||||
@@ -35,20 +36,26 @@ function resolveAvatarSource(
|
||||
agentId: string,
|
||||
opts?: { includeUiOverride?: boolean },
|
||||
): string | null {
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
const fromUiConfig = normalizeOptionalString(cfg.ui?.assistant?.avatar) ?? null;
|
||||
if (opts?.includeUiOverride) {
|
||||
const fromUiConfig = normalizeOptionalString(cfg.ui?.assistant?.avatar) ?? null;
|
||||
if (fromUiConfig) {
|
||||
if (normalizedAgentId === defaultAgentId && fromUiConfig) {
|
||||
return fromUiConfig;
|
||||
}
|
||||
}
|
||||
const fromConfig = normalizeOptionalString(resolveAgentIdentity(cfg, agentId)?.avatar) ?? null;
|
||||
const fromConfig =
|
||||
normalizeOptionalString(resolveAgentIdentity(cfg, normalizedAgentId)?.avatar) ?? null;
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
const workspace = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const workspace = resolveAgentWorkspaceDir(cfg, normalizedAgentId);
|
||||
const fromIdentity =
|
||||
normalizeOptionalString(loadAgentIdentityFromWorkspace(workspace)?.avatar) ?? null;
|
||||
return fromIdentity;
|
||||
if (fromIdentity) {
|
||||
return fromIdentity;
|
||||
}
|
||||
return opts?.includeUiOverride ? fromUiConfig : null;
|
||||
}
|
||||
|
||||
function resolveExistingPath(value: string): string {
|
||||
|
||||
@@ -3,6 +3,68 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||
|
||||
describe("resolveAssistantIdentity avatar normalization", () => {
|
||||
it("keeps ui.assistant identity authoritative for the default agent", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
name: "Main assistant",
|
||||
avatar: "M",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main", identity: { name: "Main agent", avatar: "A" } }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, agentId: "main", workspaceDir: "" })).toMatchObject({
|
||||
agentId: "main",
|
||||
name: "Main assistant",
|
||||
avatar: "M",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers non-default agent identity over global ui.assistant identity", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
name: "AI大管家",
|
||||
avatar: "M",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "fs-daying", identity: { name: "大颖", avatar: "D" } }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, agentId: "fs-daying", workspaceDir: "" })).toMatchObject(
|
||||
{
|
||||
agentId: "fs-daying",
|
||||
name: "大颖",
|
||||
avatar: "D",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to ui.assistant identity for non-default agents without their own identity", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
name: "Main assistant",
|
||||
avatar: "M",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
list: [{ id: "worker" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, agentId: "worker", workspaceDir: "" })).toMatchObject({
|
||||
agentId: "worker",
|
||||
name: "Main assistant",
|
||||
avatar: "M",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops sentence-like avatar placeholders", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
ui: {
|
||||
|
||||
@@ -86,25 +86,31 @@ export function resolveAssistantIdentity(params: {
|
||||
agentId?: string | null;
|
||||
workspaceDir?: string | null;
|
||||
}): AssistantIdentity {
|
||||
const agentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(params.cfg));
|
||||
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(params.cfg));
|
||||
const agentId = normalizeAgentId(params.agentId ?? defaultAgentId);
|
||||
const isDefaultAgent = agentId === defaultAgentId;
|
||||
const workspaceDir = params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, agentId);
|
||||
const configAssistant = params.cfg.ui?.assistant;
|
||||
const agentIdentity = resolveAgentIdentity(params.cfg, agentId);
|
||||
const fileIdentity = workspaceDir ? loadAgentIdentity(workspaceDir) : null;
|
||||
|
||||
const uiName = coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME);
|
||||
const agentName = coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME);
|
||||
const fileName = coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME);
|
||||
const name =
|
||||
coerceIdentityValue(configAssistant?.name, MAX_ASSISTANT_NAME) ??
|
||||
coerceIdentityValue(agentIdentity?.name, MAX_ASSISTANT_NAME) ??
|
||||
coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ??
|
||||
(isDefaultAgent ? (uiName ?? agentName ?? fileName) : (agentName ?? fileName ?? uiName)) ??
|
||||
DEFAULT_ASSISTANT_IDENTITY.name;
|
||||
|
||||
const avatarCandidates = [
|
||||
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR),
|
||||
const uiAvatar = coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR);
|
||||
const agentAvatarCandidates = [
|
||||
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR),
|
||||
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR),
|
||||
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR),
|
||||
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR),
|
||||
];
|
||||
const avatarCandidates = isDefaultAgent
|
||||
? [uiAvatar, ...agentAvatarCandidates]
|
||||
: [...agentAvatarCandidates, uiAvatar];
|
||||
const avatar =
|
||||
avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ??
|
||||
DEFAULT_ASSISTANT_IDENTITY.avatar;
|
||||
|
||||
@@ -101,6 +101,98 @@ describe("loadControlUiBootstrapConfig", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not apply default-agent bootstrap identity to an active non-default session", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
basePath: "",
|
||||
assistantName: "AI大管家",
|
||||
assistantAvatar: "M",
|
||||
assistantAgentId: "main",
|
||||
serverVersion: "2026.4.27",
|
||||
localMediaPreviewRoots: ["/tmp/openclaw"],
|
||||
embedSandbox: "trusted",
|
||||
allowExternalEmbedUrls: true,
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const state = {
|
||||
basePath: "",
|
||||
sessionKey: "agent:fs-daying:main",
|
||||
assistantName: "大颖",
|
||||
assistantAvatar: "D",
|
||||
assistantAvatarSource: null,
|
||||
assistantAvatarStatus: null,
|
||||
assistantAvatarReason: null,
|
||||
assistantAgentId: "fs-daying",
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts" as const,
|
||||
allowExternalEmbedUrls: false,
|
||||
serverVersion: null,
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(state.assistantName).toBe("大颖");
|
||||
expect(state.assistantAvatar).toBe("D");
|
||||
expect(state.assistantAgentId).toBe("fs-daying");
|
||||
expect(state.serverVersion).toBe("2026.4.27");
|
||||
expect(state.localMediaPreviewRoots).toEqual(["/tmp/openclaw"]);
|
||||
expect(state.embedSandboxMode).toBe("trusted");
|
||||
expect(state.allowExternalEmbedUrls).toBe(true);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps local assistant avatar override when default-agent bootstrap identity is skipped", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
basePath: "",
|
||||
assistantName: "Main",
|
||||
assistantAvatar: "M",
|
||||
assistantAgentId: "main",
|
||||
serverVersion: "2026.4.27",
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandbox: "scripts",
|
||||
allowExternalEmbedUrls: false,
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => JSON.stringify({ avatar: "data:image/png;base64,local" })),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
} as unknown as Storage);
|
||||
|
||||
const state = {
|
||||
basePath: "",
|
||||
sessionKey: "agent:worker:main",
|
||||
assistantName: "Worker",
|
||||
assistantAvatar: "W",
|
||||
assistantAvatarSource: null,
|
||||
assistantAvatarStatus: null,
|
||||
assistantAvatarReason: null,
|
||||
assistantAgentId: "worker",
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts" as const,
|
||||
allowExternalEmbedUrls: false,
|
||||
serverVersion: null,
|
||||
};
|
||||
|
||||
await loadControlUiBootstrapConfig(state);
|
||||
|
||||
expect(state.assistantName).toBe("Worker");
|
||||
expect(state.assistantAvatar).toBe("data:image/png;base64,local");
|
||||
expect(state.assistantAvatarSource).toBe("data:image/png;base64,local");
|
||||
expect(state.assistantAvatarStatus).toBe("data");
|
||||
expect(state.assistantAvatarReason).toBeNull();
|
||||
expect(state.assistantAgentId).toBe("worker");
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores failures", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
|
||||
import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts";
|
||||
import { normalizeBasePath } from "../navigation.ts";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../session-key.ts";
|
||||
import { loadLocalAssistantIdentity } from "../storage.ts";
|
||||
import { normalizeOptionalString } from "../string-coerce.ts";
|
||||
|
||||
export type ControlUiBootstrapState = {
|
||||
basePath: string;
|
||||
@@ -20,11 +22,37 @@ export type ControlUiBootstrapState = {
|
||||
localMediaPreviewRoots: string[];
|
||||
embedSandboxMode: ControlUiEmbedSandboxMode;
|
||||
allowExternalEmbedUrls: boolean;
|
||||
sessionKey?: string | null;
|
||||
hello?: { auth?: { deviceToken?: string | null } | null } | null;
|
||||
settings?: { token?: string | null } | null;
|
||||
password?: string | null;
|
||||
};
|
||||
|
||||
function resolveActiveAgentId(state: ControlUiBootstrapState): string | null {
|
||||
const sessionAgentId = parseAgentSessionKey(state.sessionKey)?.agentId;
|
||||
if (sessionAgentId) {
|
||||
return normalizeAgentId(sessionAgentId);
|
||||
}
|
||||
const currentAgentId = normalizeOptionalString(state.assistantAgentId);
|
||||
return currentAgentId ? normalizeAgentId(currentAgentId) : null;
|
||||
}
|
||||
|
||||
function resolveBootstrapAgentId(value: string | null | undefined): string | null {
|
||||
const normalized = normalizeOptionalString(value);
|
||||
return normalized ? normalizeAgentId(normalized) : null;
|
||||
}
|
||||
|
||||
function applyLocalAssistantAvatarOverride(state: ControlUiBootstrapState) {
|
||||
const localAvatar = loadLocalAssistantIdentity().avatar;
|
||||
if (!localAvatar) {
|
||||
return;
|
||||
}
|
||||
state.assistantAvatar = localAvatar;
|
||||
state.assistantAvatarSource = localAvatar;
|
||||
state.assistantAvatarStatus = "data";
|
||||
state.assistantAvatarReason = null;
|
||||
}
|
||||
|
||||
export async function loadControlUiBootstrapConfig(
|
||||
state: ControlUiBootstrapState,
|
||||
opts?: { applyIdentity?: boolean },
|
||||
@@ -70,28 +98,26 @@ export async function loadControlUiBootstrapConfig(
|
||||
}
|
||||
const parsed = (await res.json()) as ControlUiBootstrapConfig;
|
||||
if (opts?.applyIdentity !== false) {
|
||||
const normalized = normalizeAssistantIdentity({
|
||||
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;
|
||||
// Local override always wins — same pattern as the user avatar.
|
||||
const localAvatar = loadLocalAssistantIdentity().avatar;
|
||||
if (localAvatar) {
|
||||
state.assistantAvatar = localAvatar;
|
||||
state.assistantAvatarSource = localAvatar;
|
||||
state.assistantAvatarStatus = "data";
|
||||
state.assistantAvatarReason = null;
|
||||
const activeAgentId = resolveActiveAgentId(state);
|
||||
const bootstrapAgentId = resolveBootstrapAgentId(parsed.assistantAgentId ?? null);
|
||||
if (!activeAgentId || !bootstrapAgentId || activeAgentId === bootstrapAgentId) {
|
||||
const normalized = normalizeAssistantIdentity({
|
||||
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;
|
||||
}
|
||||
// Local override always wins — same pattern as the user avatar.
|
||||
applyLocalAssistantAvatarOverride(state);
|
||||
}
|
||||
state.serverVersion = parsed.serverVersion ?? null;
|
||||
state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots)
|
||||
|
||||
@@ -239,4 +239,26 @@ describe("buildAgentContext", () => {
|
||||
expect(context.workspace).toBe("/tmp/default-workspace");
|
||||
expect(context.model).toBe("openai/gpt-5.5 (+1 fallback)");
|
||||
});
|
||||
|
||||
it("prefers per-agent configured identity over runtime global identity in agent panels", () => {
|
||||
const context = buildAgentContext(
|
||||
{
|
||||
id: "fs-daying",
|
||||
name: "File-system agent",
|
||||
identity: { name: "大颖", emoji: "⚙️" },
|
||||
},
|
||||
null,
|
||||
null,
|
||||
"main",
|
||||
{
|
||||
agentId: "fs-daying",
|
||||
name: "AI大管家",
|
||||
avatar: "M",
|
||||
emoji: "🤖",
|
||||
},
|
||||
);
|
||||
|
||||
expect(context.identityName).toBe("大颖");
|
||||
expect(context.identityAvatar).toBe("⚙️");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,6 +324,25 @@ export function resolveAgentEmoji(
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveAgentTextAvatar(
|
||||
agent: { identity?: { emoji?: string; avatar?: string } },
|
||||
agentIdentity?: AgentIdentityResult | null,
|
||||
): string | null {
|
||||
const candidates = [
|
||||
normalizeOptionalString(agent.identity?.emoji),
|
||||
normalizeOptionalString(agent.identity?.avatar),
|
||||
normalizeOptionalString(agentIdentity?.emoji),
|
||||
normalizeOptionalString(agentIdentity?.avatar),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const textAvatar = resolveAssistantTextAvatar(candidate);
|
||||
if (textAvatar) {
|
||||
return textAvatar;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function agentBadgeText(agentId: string, defaultId: string | null) {
|
||||
return defaultId && agentId === defaultId ? "default" : null;
|
||||
}
|
||||
@@ -395,12 +414,14 @@ export function buildAgentContext(
|
||||
? resolveModelLabel(config.defaults?.model)
|
||||
: resolveModelLabel(agent.model);
|
||||
const identityName =
|
||||
normalizeOptionalString(agentIdentity?.name) ||
|
||||
normalizeOptionalString(agent.identity?.name) ||
|
||||
normalizeOptionalString(agent.name) ||
|
||||
normalizeOptionalString(agentIdentity?.name) ||
|
||||
config.entry?.name ||
|
||||
agent.id;
|
||||
const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—";
|
||||
const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity)
|
||||
? "custom"
|
||||
: (resolveAgentTextAvatar(agent, agentIdentity) ?? "—");
|
||||
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
|
||||
const skillCount = skillFilter?.length ?? null;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user