mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 06:21:26 +00:00
refactor: dedupe shared helpers across ui/gateway/extensions
This commit is contained in:
221
src/agents/tool-display-common.ts
Normal file
221
src/agents/tool-display-common.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
export type ToolDisplayActionSpec = {
|
||||
label?: string;
|
||||
detailKeys?: string[];
|
||||
};
|
||||
|
||||
export type ToolDisplaySpec = {
|
||||
title?: string;
|
||||
label?: string;
|
||||
detailKeys?: string[];
|
||||
actions?: Record<string, ToolDisplayActionSpec>;
|
||||
};
|
||||
|
||||
export type CoerceDisplayValueOptions = {
|
||||
includeFalse?: boolean;
|
||||
includeZero?: boolean;
|
||||
includeNonFinite?: boolean;
|
||||
maxStringChars?: number;
|
||||
maxArrayEntries?: number;
|
||||
};
|
||||
|
||||
export function normalizeToolName(name?: string): string {
|
||||
return (name ?? "tool").trim();
|
||||
}
|
||||
|
||||
export function defaultTitle(name: string): string {
|
||||
const cleaned = name.replace(/_/g, " ").trim();
|
||||
if (!cleaned) {
|
||||
return "Tool";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\s+/)
|
||||
.map((part) =>
|
||||
part.length <= 2 && part.toUpperCase() === part
|
||||
? part
|
||||
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function normalizeVerb(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
export function coerceDisplayValue(
|
||||
value: unknown,
|
||||
opts: CoerceDisplayValueOptions = {},
|
||||
): string | undefined {
|
||||
const maxStringChars = opts.maxStringChars ?? 160;
|
||||
const maxArrayEntries = opts.maxArrayEntries ?? 3;
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
|
||||
if (!firstLine) {
|
||||
return undefined;
|
||||
}
|
||||
if (firstLine.length > maxStringChars) {
|
||||
return `${firstLine.slice(0, Math.max(0, maxStringChars - 3))}…`;
|
||||
}
|
||||
return firstLine;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
if (!value && !opts.includeFalse) {
|
||||
return undefined;
|
||||
}
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
return opts.includeNonFinite ? String(value) : undefined;
|
||||
}
|
||||
if (value === 0 && !opts.includeZero) {
|
||||
return undefined;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((item) => coerceDisplayValue(item, opts))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const preview = values.slice(0, maxArrayEntries).join(", ");
|
||||
return values.length > maxArrayEntries ? `${preview}…` : preview;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function lookupValueByPath(args: unknown, path: string): unknown {
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
let current: unknown = args;
|
||||
for (const segment of path.split(".")) {
|
||||
if (!segment) {
|
||||
return undefined;
|
||||
}
|
||||
if (!current || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
current = record[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function formatDetailKey(raw: string, overrides: Record<string, string> = {}): string {
|
||||
const segments = raw.split(".").filter(Boolean);
|
||||
const last = segments.at(-1) ?? raw;
|
||||
const override = overrides[last];
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const cleaned = last.replace(/_/g, " ").replace(/-/g, " ");
|
||||
const spaced = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||||
return spaced.trim().toLowerCase() || last.toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveReadDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = typeof record.offset === "number" ? record.offset : undefined;
|
||||
const limit = typeof record.limit === "number" ? record.limit : undefined;
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
return `${path}:${offset}-${offset + limit}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function resolveWriteDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
return path;
|
||||
}
|
||||
|
||||
export function resolveActionSpec(
|
||||
spec: ToolDisplaySpec | undefined,
|
||||
action: string | undefined,
|
||||
): ToolDisplayActionSpec | undefined {
|
||||
if (!spec || !action) {
|
||||
return undefined;
|
||||
}
|
||||
return spec.actions?.[action] ?? undefined;
|
||||
}
|
||||
|
||||
export function resolveDetailFromKeys(
|
||||
args: unknown,
|
||||
keys: string[],
|
||||
opts: {
|
||||
mode: "first" | "summary";
|
||||
coerce?: CoerceDisplayValueOptions;
|
||||
maxEntries?: number;
|
||||
formatKey?: (raw: string) => string;
|
||||
},
|
||||
): string | undefined {
|
||||
if (opts.mode === "first") {
|
||||
for (const key of keys) {
|
||||
const value = lookupValueByPath(args, key);
|
||||
const display = coerceDisplayValue(value, opts.coerce);
|
||||
if (display) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries: Array<{ label: string; value: string }> = [];
|
||||
for (const key of keys) {
|
||||
const value = lookupValueByPath(args, key);
|
||||
const display = coerceDisplayValue(value, opts.coerce);
|
||||
if (!display) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ label: opts.formatKey ? opts.formatKey(key) : key, value: display });
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
return entries[0].value;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: Array<{ label: string; value: string }> = [];
|
||||
for (const entry of entries) {
|
||||
const token = `${entry.label}:${entry.value}`;
|
||||
if (seen.has(token)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(token);
|
||||
unique.push(entry);
|
||||
}
|
||||
if (unique.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return unique
|
||||
.slice(0, opts.maxEntries ?? 8)
|
||||
.map((entry) => `${entry.label} ${entry.value}`)
|
||||
.join(" · ");
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { redactToolDetail } from "../logging/redact.js";
|
||||
import { shortenHomeInString } from "../utils.js";
|
||||
import {
|
||||
defaultTitle,
|
||||
formatDetailKey,
|
||||
normalizeToolName,
|
||||
normalizeVerb,
|
||||
resolveActionSpec,
|
||||
resolveDetailFromKeys,
|
||||
resolveReadDetail,
|
||||
resolveWriteDetail,
|
||||
type ToolDisplaySpec as ToolDisplaySpecBase,
|
||||
} from "./tool-display-common.js";
|
||||
import TOOL_DISPLAY_JSON from "./tool-display.json" with { type: "json" };
|
||||
|
||||
type ToolDisplayActionSpec = {
|
||||
label?: string;
|
||||
detailKeys?: string[];
|
||||
};
|
||||
|
||||
type ToolDisplaySpec = {
|
||||
type ToolDisplaySpec = ToolDisplaySpecBase & {
|
||||
emoji?: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
detailKeys?: string[];
|
||||
actions?: Record<string, ToolDisplayActionSpec>;
|
||||
};
|
||||
|
||||
type ToolDisplayConfig = {
|
||||
@@ -53,172 +55,6 @@ const DETAIL_LABEL_OVERRIDES: Record<string, string> = {
|
||||
};
|
||||
const MAX_DETAIL_ENTRIES = 8;
|
||||
|
||||
function normalizeToolName(name?: string): string {
|
||||
return (name ?? "tool").trim();
|
||||
}
|
||||
|
||||
function defaultTitle(name: string): string {
|
||||
const cleaned = name.replace(/_/g, " ").trim();
|
||||
if (!cleaned) {
|
||||
return "Tool";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\s+/)
|
||||
.map((part) =>
|
||||
part.length <= 2 && part.toUpperCase() === part
|
||||
? part
|
||||
: `${part.at(0)?.toUpperCase() ?? ""}${part.slice(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function normalizeVerb(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function coerceDisplayValue(value: unknown): string | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
|
||||
if (!firstLine) {
|
||||
return undefined;
|
||||
}
|
||||
return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "true" : undefined;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value) || value === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const values = value
|
||||
.map((item) => coerceDisplayValue(item))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const preview = values.slice(0, 3).join(", ");
|
||||
return values.length > 3 ? `${preview}…` : preview;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function lookupValueByPath(args: unknown, path: string): unknown {
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
let current: unknown = args;
|
||||
for (const segment of path.split(".")) {
|
||||
if (!segment) {
|
||||
return undefined;
|
||||
}
|
||||
if (!current || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = current as Record<string, unknown>;
|
||||
current = record[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function formatDetailKey(raw: string): string {
|
||||
const segments = raw.split(".").filter(Boolean);
|
||||
const last = segments.at(-1) ?? raw;
|
||||
const override = DETAIL_LABEL_OVERRIDES[last];
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const cleaned = last.replace(/_/g, " ").replace(/-/g, " ");
|
||||
const spaced = cleaned.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
||||
return spaced.trim().toLowerCase() || last.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveDetailFromKeys(args: unknown, keys: string[]): string | undefined {
|
||||
const entries: Array<{ label: string; value: string }> = [];
|
||||
for (const key of keys) {
|
||||
const value = lookupValueByPath(args, key);
|
||||
const display = coerceDisplayValue(value);
|
||||
if (!display) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ label: formatDetailKey(key), value: display });
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
return entries[0].value;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique: Array<{ label: string; value: string }> = [];
|
||||
for (const entry of entries) {
|
||||
const token = `${entry.label}:${entry.value}`;
|
||||
if (seen.has(token)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(token);
|
||||
unique.push(entry);
|
||||
}
|
||||
if (unique.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return unique
|
||||
.slice(0, MAX_DETAIL_ENTRIES)
|
||||
.map((entry) => `${entry.label} ${entry.value}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function resolveReadDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = typeof record.offset === "number" ? record.offset : undefined;
|
||||
const limit = typeof record.limit === "number" ? record.limit : undefined;
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
return `${path}:${offset}-${offset + limit}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function resolveWriteDetail(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = args as Record<string, unknown>;
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
return path;
|
||||
}
|
||||
|
||||
function resolveActionSpec(
|
||||
spec: ToolDisplaySpec | undefined,
|
||||
action: string | undefined,
|
||||
): ToolDisplayActionSpec | undefined {
|
||||
if (!spec || !action) {
|
||||
return undefined;
|
||||
}
|
||||
return spec.actions?.[action] ?? undefined;
|
||||
}
|
||||
|
||||
export function resolveToolDisplay(params: {
|
||||
name?: string;
|
||||
args?: unknown;
|
||||
@@ -248,7 +84,11 @@ export function resolveToolDisplay(params: {
|
||||
|
||||
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
|
||||
if (!detail && detailKeys.length > 0) {
|
||||
detail = resolveDetailFromKeys(params.args, detailKeys);
|
||||
detail = resolveDetailFromKeys(params.args, detailKeys, {
|
||||
mode: "summary",
|
||||
maxEntries: MAX_DETAIL_ENTRIES,
|
||||
formatKey: (raw) => formatDetailKey(raw, DETAIL_LABEL_OVERRIDES),
|
||||
});
|
||||
}
|
||||
|
||||
if (!detail && params.meta) {
|
||||
|
||||
Reference in New Issue
Block a user