mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 06:57:26 +00:00
refactor: dedupe chat envelope + daemon output + skills UI
This commit is contained in:
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
|
||||||
import {
|
import {
|
||||||
formatGatewayServiceDescription,
|
formatGatewayServiceDescription,
|
||||||
GATEWAY_LAUNCH_AGENT_LABEL,
|
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||||
@@ -14,16 +13,11 @@ import {
|
|||||||
buildLaunchAgentPlist as buildLaunchAgentPlistImpl,
|
buildLaunchAgentPlist as buildLaunchAgentPlistImpl,
|
||||||
readLaunchAgentProgramArgumentsFromFile,
|
readLaunchAgentProgramArgumentsFromFile,
|
||||||
} from "./launchd-plist.js";
|
} from "./launchd-plist.js";
|
||||||
|
import { formatLine, toPosixPath } from "./output.js";
|
||||||
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
|
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
|
||||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const toPosixPath = (value: string) => value.replace(/\\/g, "/");
|
|
||||||
|
|
||||||
const formatLine = (label: string, value: string) => {
|
|
||||||
const rich = isRich();
|
|
||||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveLaunchAgentLabel(args?: { env?: Record<string, string | undefined> }): string {
|
function resolveLaunchAgentLabel(args?: { env?: Record<string, string | undefined> }): string {
|
||||||
const envLabel = args?.env?.OPENCLAW_LAUNCHD_LABEL?.trim();
|
const envLabel = args?.env?.OPENCLAW_LAUNCHD_LABEL?.trim();
|
||||||
|
|||||||
8
src/daemon/output.ts
Normal file
8
src/daemon/output.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
|
export const toPosixPath = (value: string) => value.replace(/\\/g, "/");
|
||||||
|
|
||||||
|
export function formatLine(label: string, value: string): string {
|
||||||
|
const rich = isRich();
|
||||||
|
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
||||||
|
}
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
|
||||||
import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js";
|
import { formatGatewayServiceDescription, resolveGatewayWindowsTaskName } from "./constants.js";
|
||||||
|
import { formatLine } from "./output.js";
|
||||||
import { resolveGatewayStateDir } from "./paths.js";
|
import { resolveGatewayStateDir } from "./paths.js";
|
||||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||||
import { execSchtasks } from "./schtasks-exec.js";
|
import { execSchtasks } from "./schtasks-exec.js";
|
||||||
|
|
||||||
const formatLine = (label: string, value: string) => {
|
|
||||||
const rich = isRich();
|
|
||||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveTaskName(env: Record<string, string | undefined>): string {
|
function resolveTaskName(env: Record<string, string | undefined>): string {
|
||||||
const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim();
|
const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim();
|
||||||
if (override) {
|
if (override) {
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
|
||||||
import {
|
import {
|
||||||
formatGatewayServiceDescription,
|
formatGatewayServiceDescription,
|
||||||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
||||||
resolveGatewaySystemdServiceName,
|
resolveGatewaySystemdServiceName,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
import { formatLine, toPosixPath } from "./output.js";
|
||||||
import { resolveHomeDir } from "./paths.js";
|
import { resolveHomeDir } from "./paths.js";
|
||||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||||
import {
|
import {
|
||||||
@@ -23,12 +23,6 @@ import {
|
|||||||
} from "./systemd-unit.js";
|
} from "./systemd-unit.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const toPosixPath = (value: string) => value.replace(/\\/g, "/");
|
|
||||||
|
|
||||||
const formatLine = (label: string, value: string) => {
|
|
||||||
const rich = isRich();
|
|
||||||
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveSystemdUnitPathForName(
|
function resolveSystemdUnitPathForName(
|
||||||
env: Record<string, string | undefined>,
|
env: Record<string, string | undefined>,
|
||||||
|
|||||||
@@ -1,52 +1,6 @@
|
|||||||
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
|
import { stripEnvelope, stripMessageIdHints } from "../shared/chat-envelope.js";
|
||||||
const ENVELOPE_CHANNELS = [
|
|
||||||
"WebChat",
|
|
||||||
"WhatsApp",
|
|
||||||
"Telegram",
|
|
||||||
"Signal",
|
|
||||||
"Slack",
|
|
||||||
"Discord",
|
|
||||||
"Google Chat",
|
|
||||||
"iMessage",
|
|
||||||
"Teams",
|
|
||||||
"Matrix",
|
|
||||||
"Zalo",
|
|
||||||
"Zalo Personal",
|
|
||||||
"BlueBubbles",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
|
export { stripEnvelope };
|
||||||
|
|
||||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
|
||||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stripEnvelope(text: string): string {
|
|
||||||
const match = text.match(ENVELOPE_PREFIX);
|
|
||||||
if (!match) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
const header = match[1] ?? "";
|
|
||||||
if (!looksLikeEnvelopeHeader(header)) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
return text.slice(match[0].length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripMessageIdHints(text: string): string {
|
|
||||||
if (!text.includes("[message_id:")) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
const lines = text.split(/\r?\n/);
|
|
||||||
const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line));
|
|
||||||
return filtered.length === lines.length ? text : filtered.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; changed: boolean } {
|
function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; changed: boolean } {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|||||||
49
src/shared/chat-envelope.ts
Normal file
49
src/shared/chat-envelope.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
|
||||||
|
const ENVELOPE_CHANNELS = [
|
||||||
|
"WebChat",
|
||||||
|
"WhatsApp",
|
||||||
|
"Telegram",
|
||||||
|
"Signal",
|
||||||
|
"Slack",
|
||||||
|
"Discord",
|
||||||
|
"Google Chat",
|
||||||
|
"iMessage",
|
||||||
|
"Teams",
|
||||||
|
"Matrix",
|
||||||
|
"Zalo",
|
||||||
|
"Zalo Personal",
|
||||||
|
"BlueBubbles",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
|
||||||
|
|
||||||
|
function looksLikeEnvelopeHeader(header: string): boolean {
|
||||||
|
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripEnvelope(text: string): string {
|
||||||
|
const match = text.match(ENVELOPE_PREFIX);
|
||||||
|
if (!match) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const header = match[1] ?? "";
|
||||||
|
if (!looksLikeEnvelopeHeader(header)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.slice(match[0].length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripMessageIdHints(text: string): string {
|
||||||
|
if (!text.includes("[message_id:")) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line));
|
||||||
|
return filtered.length === lines.length ? text : filtered.join("\n");
|
||||||
|
}
|
||||||
@@ -1,46 +1,9 @@
|
|||||||
|
import { stripEnvelope } from "../../../../src/shared/chat-envelope.js";
|
||||||
import { stripThinkingTags } from "../format.ts";
|
import { stripThinkingTags } from "../format.ts";
|
||||||
|
|
||||||
const ENVELOPE_PREFIX = /^\[([^\]]+)\]\s*/;
|
|
||||||
const ENVELOPE_CHANNELS = [
|
|
||||||
"WebChat",
|
|
||||||
"WhatsApp",
|
|
||||||
"Telegram",
|
|
||||||
"Signal",
|
|
||||||
"Slack",
|
|
||||||
"Discord",
|
|
||||||
"iMessage",
|
|
||||||
"Teams",
|
|
||||||
"Matrix",
|
|
||||||
"Zalo",
|
|
||||||
"Zalo Personal",
|
|
||||||
"BlueBubbles",
|
|
||||||
];
|
|
||||||
|
|
||||||
const textCache = new WeakMap<object, string | null>();
|
const textCache = new WeakMap<object, string | null>();
|
||||||
const thinkingCache = new WeakMap<object, string | null>();
|
const thinkingCache = new WeakMap<object, string | null>();
|
||||||
|
|
||||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
|
||||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stripEnvelope(text: string): string {
|
|
||||||
const match = text.match(ENVELOPE_PREFIX);
|
|
||||||
if (!match) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
const header = match[1] ?? "";
|
|
||||||
if (!looksLikeEnvelopeHeader(header)) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
return text.slice(match[0].length);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractText(message: unknown): string | null {
|
export function extractText(message: unknown): string | null {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
const role = typeof m.role === "string" ? m.role : "";
|
const role = typeof m.role === "string" ? m.role : "";
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
TOOL_SECTIONS,
|
TOOL_SECTIONS,
|
||||||
} from "./agents-utils.ts";
|
} from "./agents-utils.ts";
|
||||||
import { groupSkills } from "./skills-grouping.ts";
|
import { groupSkills } from "./skills-grouping.ts";
|
||||||
|
import {
|
||||||
|
computeSkillMissing,
|
||||||
|
computeSkillReasons,
|
||||||
|
renderSkillStatusChips,
|
||||||
|
} from "./skills-shared.ts";
|
||||||
|
|
||||||
export function renderAgentTools(params: {
|
export function renderAgentTools(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -436,37 +441,14 @@ function renderAgentSkillRow(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const enabled = params.usingAllowlist ? params.allowSet.has(skill.name) : true;
|
const enabled = params.usingAllowlist ? params.allowSet.has(skill.name) : true;
|
||||||
const missing = [
|
const missing = computeSkillMissing(skill);
|
||||||
...skill.missing.bins.map((b) => `bin:${b}`),
|
const reasons = computeSkillReasons(skill);
|
||||||
...skill.missing.env.map((e) => `env:${e}`),
|
|
||||||
...skill.missing.config.map((c) => `config:${c}`),
|
|
||||||
...skill.missing.os.map((o) => `os:${o}`),
|
|
||||||
];
|
|
||||||
const reasons: string[] = [];
|
|
||||||
if (skill.disabled) {
|
|
||||||
reasons.push("disabled");
|
|
||||||
}
|
|
||||||
if (skill.blockedByAllowlist) {
|
|
||||||
reasons.push("blocked by allowlist");
|
|
||||||
}
|
|
||||||
return html`
|
return html`
|
||||||
<div class="list-item agent-skill-row">
|
<div class="list-item agent-skill-row">
|
||||||
<div class="list-main">
|
<div class="list-main">
|
||||||
<div class="list-title">${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}</div>
|
<div class="list-title">${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}</div>
|
||||||
<div class="list-sub">${skill.description}</div>
|
<div class="list-sub">${skill.description}</div>
|
||||||
<div class="chip-row" style="margin-top: 6px;">
|
${renderSkillStatusChips({ skill })}
|
||||||
<span class="chip">${skill.source}</span>
|
|
||||||
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
|
|
||||||
${skill.eligible ? "eligible" : "blocked"}
|
|
||||||
</span>
|
|
||||||
${
|
|
||||||
skill.disabled
|
|
||||||
? html`
|
|
||||||
<span class="chip chip-warn">disabled</span>
|
|
||||||
`
|
|
||||||
: nothing
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
${
|
${
|
||||||
missing.length > 0
|
missing.length > 0
|
||||||
? html`<div class="muted" style="margin-top: 6px;">Missing: ${missing.join(", ")}</div>`
|
? html`<div class="muted" style="margin-top: 6px;">Missing: ${missing.join(", ")}</div>`
|
||||||
|
|||||||
52
ui/src/ui/views/skills-shared.ts
Normal file
52
ui/src/ui/views/skills-shared.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { html, nothing } from "lit";
|
||||||
|
import type { SkillStatusEntry } from "../types.ts";
|
||||||
|
|
||||||
|
export function computeSkillMissing(skill: SkillStatusEntry): string[] {
|
||||||
|
return [
|
||||||
|
...skill.missing.bins.map((b) => `bin:${b}`),
|
||||||
|
...skill.missing.env.map((e) => `env:${e}`),
|
||||||
|
...skill.missing.config.map((c) => `config:${c}`),
|
||||||
|
...skill.missing.os.map((o) => `os:${o}`),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSkillReasons(skill: SkillStatusEntry): string[] {
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (skill.disabled) {
|
||||||
|
reasons.push("disabled");
|
||||||
|
}
|
||||||
|
if (skill.blockedByAllowlist) {
|
||||||
|
reasons.push("blocked by allowlist");
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSkillStatusChips(params: {
|
||||||
|
skill: SkillStatusEntry;
|
||||||
|
showBundledBadge?: boolean;
|
||||||
|
}) {
|
||||||
|
const skill = params.skill;
|
||||||
|
const showBundledBadge = Boolean(params.showBundledBadge);
|
||||||
|
return html`
|
||||||
|
<div class="chip-row" style="margin-top: 6px;">
|
||||||
|
<span class="chip">${skill.source}</span>
|
||||||
|
${
|
||||||
|
showBundledBadge
|
||||||
|
? html`
|
||||||
|
<span class="chip">bundled</span>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
|
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
|
||||||
|
${skill.eligible ? "eligible" : "blocked"}
|
||||||
|
</span>
|
||||||
|
${
|
||||||
|
skill.disabled
|
||||||
|
? html`
|
||||||
|
<span class="chip chip-warn">disabled</span>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,11 @@ import type { SkillMessageMap } from "../controllers/skills.ts";
|
|||||||
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
|
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
|
||||||
import { clampText } from "../format.ts";
|
import { clampText } from "../format.ts";
|
||||||
import { groupSkills } from "./skills-grouping.ts";
|
import { groupSkills } from "./skills-grouping.ts";
|
||||||
|
import {
|
||||||
|
computeSkillMissing,
|
||||||
|
computeSkillReasons,
|
||||||
|
renderSkillStatusChips,
|
||||||
|
} from "./skills-shared.ts";
|
||||||
|
|
||||||
export type SkillsProps = {
|
export type SkillsProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -94,19 +99,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
|||||||
const message = props.messages[skill.skillKey] ?? null;
|
const message = props.messages[skill.skillKey] ?? null;
|
||||||
const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0;
|
const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0;
|
||||||
const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled");
|
const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled");
|
||||||
const missing = [
|
const missing = computeSkillMissing(skill);
|
||||||
...skill.missing.bins.map((b) => `bin:${b}`),
|
const reasons = computeSkillReasons(skill);
|
||||||
...skill.missing.env.map((e) => `env:${e}`),
|
|
||||||
...skill.missing.config.map((c) => `config:${c}`),
|
|
||||||
...skill.missing.os.map((o) => `os:${o}`),
|
|
||||||
];
|
|
||||||
const reasons: string[] = [];
|
|
||||||
if (skill.disabled) {
|
|
||||||
reasons.push("disabled");
|
|
||||||
}
|
|
||||||
if (skill.blockedByAllowlist) {
|
|
||||||
reasons.push("blocked by allowlist");
|
|
||||||
}
|
|
||||||
return html`
|
return html`
|
||||||
<div class="list-item">
|
<div class="list-item">
|
||||||
<div class="list-main">
|
<div class="list-main">
|
||||||
@@ -114,26 +108,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
|
|||||||
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
|
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
|
||||||
</div>
|
</div>
|
||||||
<div class="list-sub">${clampText(skill.description, 140)}</div>
|
<div class="list-sub">${clampText(skill.description, 140)}</div>
|
||||||
<div class="chip-row" style="margin-top: 6px;">
|
${renderSkillStatusChips({ skill, showBundledBadge })}
|
||||||
<span class="chip">${skill.source}</span>
|
|
||||||
${
|
|
||||||
showBundledBadge
|
|
||||||
? html`
|
|
||||||
<span class="chip">bundled</span>
|
|
||||||
`
|
|
||||||
: nothing
|
|
||||||
}
|
|
||||||
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
|
|
||||||
${skill.eligible ? "eligible" : "blocked"}
|
|
||||||
</span>
|
|
||||||
${
|
|
||||||
skill.disabled
|
|
||||||
? html`
|
|
||||||
<span class="chip chip-warn">disabled</span>
|
|
||||||
`
|
|
||||||
: nothing
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
${
|
${
|
||||||
missing.length > 0
|
missing.length > 0
|
||||||
? html`
|
? html`
|
||||||
|
|||||||
Reference in New Issue
Block a user