Merge pull request #1270 from pikasTech/fix/issue-1263-1264-workbench-probe
fix(web): 归一化 Workbench session 标题与 probe 证据
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
@@ -99,6 +100,7 @@ export async function runProbe(args) {
|
|||||||
browser = await launchChromium(chromium);
|
browser = await launchChromium(chromium);
|
||||||
const context = await browser.newContext({ viewport: { width, height } });
|
const context = await browser.newContext({ viewport: { width, height } });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
const bootstrapRoutes = observeBootstrapRoutes(page);
|
||||||
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
|
await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
|
||||||
await waitForAuthBootstrap(page, args, actions);
|
await waitForAuthBootstrap(page, args, actions);
|
||||||
await maybeLogin(page, args, actions);
|
await maybeLogin(page, args, actions);
|
||||||
@@ -110,7 +112,7 @@ export async function runProbe(args) {
|
|||||||
if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions);
|
if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions);
|
||||||
await waitForMessageCards(page, args, actions);
|
await waitForMessageCards(page, args, actions);
|
||||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||||
const dom = await collectDomEvidence(page);
|
const dom = await collectDomEvidence(page, args);
|
||||||
const promptValidation = validatePromptFinalResponse(dom, args);
|
const promptValidation = validatePromptFinalResponse(dom, args);
|
||||||
result = {
|
result = {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -123,14 +125,15 @@ export async function runProbe(args) {
|
|||||||
viewport: { width, height },
|
viewport: { width, height },
|
||||||
browser: browser.hwlabLaunchPlan ?? null,
|
browser: browser.hwlabLaunchPlan ?? null,
|
||||||
actions,
|
actions,
|
||||||
|
bootstrapRoutes: summarizeBootstrapRoutes(bootstrapRoutes),
|
||||||
dom,
|
dom,
|
||||||
promptValidation,
|
promptValidation,
|
||||||
artifacts: { reportPath, screenshotPath },
|
artifacts: { reportPath, screenshotPath },
|
||||||
safety: {
|
safety: {
|
||||||
repoOwnedLauncher: true,
|
repoOwnedLauncher: true,
|
||||||
directChromiumLaunch: false,
|
directChromiumLaunch: false,
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
conversationSelected: Boolean(args.conversationId),
|
conversationSelected: Boolean(args.conversationId),
|
||||||
freshSessionRequested: args.freshSession,
|
freshSessionRequested: args.freshSession,
|
||||||
promptSubmitted: Boolean(args.message),
|
promptSubmitted: Boolean(args.message),
|
||||||
cancelRunningRequested: args.cancelRunning
|
cancelRunningRequested: args.cancelRunning
|
||||||
@@ -154,6 +157,13 @@ export async function runProbe(args) {
|
|||||||
}
|
}
|
||||||
await mkdir(path.dirname(reportPath), { recursive: true });
|
await mkdir(path.dirname(reportPath), { recursive: true });
|
||||||
await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
||||||
|
result.artifacts = {
|
||||||
|
...(result.artifacts ?? {}),
|
||||||
|
reportPath,
|
||||||
|
reportSha256: await fileSha256(reportPath),
|
||||||
|
screenshotPath,
|
||||||
|
screenshotSha256: await fileSha256(screenshotPath)
|
||||||
|
};
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,26 +307,14 @@ async function maybeLogin(page, args, actions) {
|
|||||||
actions.push({ action: "login", skipped: "login-form-not-visible", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
|
actions.push({ action: "login", skipped: "login-form-not-visible", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cardInputs = page.locator(".login-card input");
|
await submitCurrentLoginForm(page, args, actions, "current-autocomplete");
|
||||||
if ((await cardInputs.count().catch(() => 0)) < 2) {
|
|
||||||
actions.push({ action: "login", skipped: "login-form-incomplete", selectorMode: "login-card", inputCount: await cardInputs.count().catch(() => 0), authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await cardInputs.nth(0).fill(args.user);
|
|
||||||
await cardInputs.nth(1).fill(args.pass);
|
|
||||||
await page.locator(".login-card button[type=submit]").click();
|
|
||||||
actions.push({ action: "login", user: args.user, selectorMode: "login-card" });
|
|
||||||
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
|
|
||||||
const finalUrl = new URL(page.url());
|
|
||||||
if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
|
|
||||||
throw new Error("login credentials leaked into URL query");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForLoginSurface(page, args) {
|
async function waitForLoginSurface(page, args) {
|
||||||
const handle = await page.waitForFunction(() => {
|
const handle = await page.waitForFunction(() => {
|
||||||
if (document.querySelector("#workspace")) return "workspace";
|
if (document.querySelector("#workspace")) return "workspace";
|
||||||
if (document.querySelector("#login-username")) return "legacy-id";
|
if (document.querySelector("#login-username")) return "legacy-id";
|
||||||
|
if (document.querySelector('input[autocomplete="username"]') && document.querySelector('input[autocomplete="current-password"]')) return "login-card";
|
||||||
if (document.querySelector(".login-card input")) return "login-card";
|
if (document.querySelector(".login-card input")) return "login-card";
|
||||||
return "";
|
return "";
|
||||||
}, null, { timeout: args.timeoutMs }).catch(() => null);
|
}, null, { timeout: args.timeoutMs }).catch(() => null);
|
||||||
@@ -324,11 +322,38 @@ async function waitForLoginSurface(page, args) {
|
|||||||
return handle.jsonValue().catch(() => null);
|
return handle.jsonValue().catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitCurrentLoginForm(page, args, actions, selectorMode) {
|
||||||
|
const username = page.locator('input[autocomplete="username"], .login-card input').first();
|
||||||
|
const password = page.locator('input[autocomplete="current-password"], .login-card input[type="password"], input[type="password"]').first();
|
||||||
|
const submit = page.locator('.login-card button[type="submit"], button[type="submit"]').first();
|
||||||
|
const inputCount = await page.locator(".login-card input, input[autocomplete=\"username\"], input[autocomplete=\"current-password\"]").count().catch(() => 0);
|
||||||
|
if (!(await username.isVisible({ timeout: 5000 }).catch(() => false)) || !(await password.isVisible({ timeout: 5000 }).catch(() => false))) {
|
||||||
|
actions.push({ action: "login", skipped: "login-form-incomplete", selectorMode, inputCount, authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await username.fill(args.user);
|
||||||
|
await password.fill(args.pass);
|
||||||
|
await Promise.allSettled([
|
||||||
|
page.waitForURL(/\/workbench/u, { timeout: Math.min(args.timeoutMs, 15000) }),
|
||||||
|
submit.click()
|
||||||
|
]);
|
||||||
|
actions.push({ action: "login", user: args.user, selectorMode });
|
||||||
|
await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
|
||||||
|
assertNoCredentialLeak(page.url());
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoCredentialLeak(value) {
|
||||||
|
const finalUrl = new URL(value);
|
||||||
|
if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
|
||||||
|
throw new Error("login credentials leaked into URL query");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForAuthBootstrap(page, args, actions) {
|
async function waitForAuthBootstrap(page, args, actions) {
|
||||||
const timeout = Math.min(args.timeoutMs, 12000);
|
const timeout = Math.min(args.timeoutMs, 12000);
|
||||||
const settled = await page.waitForFunction(() => {
|
const settled = await page.waitForFunction(() => {
|
||||||
const authState = document.body.getAttribute("data-auth-state");
|
const authState = document.body.getAttribute("data-auth-state");
|
||||||
const loginVisible = Boolean(document.querySelector("#login-username"));
|
const loginVisible = Boolean(document.querySelector("#login-username") || document.querySelector('input[autocomplete="username"]') || document.querySelector(".login-card input"));
|
||||||
const workspaceVisible = Boolean(document.querySelector("#workspace"));
|
const workspaceVisible = Boolean(document.querySelector("#workspace"));
|
||||||
return authState !== "checking" || loginVisible || workspaceVisible;
|
return authState !== "checking" || loginVisible || workspaceVisible;
|
||||||
}, null, { timeout }).catch(() => null);
|
}, null, { timeout }).catch(() => null);
|
||||||
@@ -560,11 +585,18 @@ async function collectLatestAgentMessageState(page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDomEvidence(page) {
|
async function collectDomEvidence(page, args) {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(async ({ projectId }) => {
|
||||||
const workspace = document.querySelector("#workspace");
|
const workspace = document.querySelector("#workspace");
|
||||||
const list = document.querySelector("#conversation-list");
|
const list = document.querySelector("#conversation-list");
|
||||||
const cards = [...document.querySelectorAll(".message-card")];
|
const cards = [...document.querySelectorAll(".message-card")];
|
||||||
|
const conversationResponse = await fetch(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { credentials: "same-origin" }).catch(() => null);
|
||||||
|
const conversationBody = conversationResponse ? await conversationResponse.json().catch(() => null) : null;
|
||||||
|
const conversations = Array.isArray(conversationBody?.conversations) ? conversationBody.conversations : [];
|
||||||
|
const sessionTabs = [...document.querySelectorAll("#session-tabs .session-tab")];
|
||||||
|
const activeSessionTab = sessionTabs.find((item) => item.getAttribute("data-active") === "true") ?? null;
|
||||||
|
const sessionIds = conversations.map((item) => item?.sessionId).filter((value) => typeof value === "string" && value).slice(0, 8);
|
||||||
|
const traceIds = conversations.map((item) => item?.lastTraceId).filter((value) => typeof value === "string" && value).slice(0, 8);
|
||||||
const scroll = workspace ? {
|
const scroll = workspace ? {
|
||||||
scrollTop: Math.round(workspace.scrollTop),
|
scrollTop: Math.round(workspace.scrollTop),
|
||||||
scrollHeight: Math.round(workspace.scrollHeight),
|
scrollHeight: Math.round(workspace.scrollHeight),
|
||||||
@@ -582,6 +614,16 @@ async function collectDomEvidence(page) {
|
|||||||
conversationList: Boolean(list),
|
conversationList: Boolean(list),
|
||||||
sessionCreate: Boolean(document.querySelector("#session-create"))
|
sessionCreate: Boolean(document.querySelector("#session-create"))
|
||||||
},
|
},
|
||||||
|
sessionRail: {
|
||||||
|
tabCount: sessionTabs.length,
|
||||||
|
activeTitle: activeSessionTab?.querySelector("strong")?.textContent?.trim() ?? null,
|
||||||
|
objectTitleCount: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").filter((text) => text.includes("[object Object]")).length,
|
||||||
|
titles: sessionTabs.map((item) => item.querySelector("strong")?.textContent?.trim() ?? "").slice(0, 8),
|
||||||
|
sessionIds,
|
||||||
|
traceIds,
|
||||||
|
conversationsStatus: conversationResponse?.status ?? null,
|
||||||
|
conversationsCount: conversations.length
|
||||||
|
},
|
||||||
messageCount: cards.length,
|
messageCount: cards.length,
|
||||||
messageRoles: cards.map((card) => card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system") ?? "unknown"),
|
messageRoles: cards.map((card) => card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system") ?? "unknown"),
|
||||||
messages: cards.map((card) => {
|
messages: cards.map((card) => {
|
||||||
@@ -630,7 +672,37 @@ async function collectDomEvidence(page) {
|
|||||||
scroll,
|
scroll,
|
||||||
textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800)
|
textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800)
|
||||||
};
|
};
|
||||||
|
}, { projectId: args.projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeBootstrapRoutes(page) {
|
||||||
|
const routes = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
if (!["/auth/bootstrap", "/auth/session", "/auth/workspace-bootstrap", "/v1/workbench/workspace"].includes(url.pathname)) return;
|
||||||
|
routes.push({ method: request.method(), path: url.pathname });
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed browser-internal URLs.
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeBootstrapRoutes(routes) {
|
||||||
|
const paths = routes.map((route) => route.path);
|
||||||
|
return {
|
||||||
|
authBootstrapRequested: paths.includes("/auth/bootstrap"),
|
||||||
|
authSessionRequested: paths.includes("/auth/session"),
|
||||||
|
workbenchWorkspaceRequested: paths.includes("/v1/workbench/workspace"),
|
||||||
|
legacyWorkspaceBootstrapRequested: paths.includes("/auth/workspace-bootstrap"),
|
||||||
|
routes: routes.slice(0, 20)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileSha256(file) {
|
||||||
|
if (!file || !fs.existsSync(file)) return null;
|
||||||
|
return createHash("sha256").update(await readFile(file)).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseViewport(value) {
|
function parseViewport(value) {
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ test("R2 session tabs expose label, model metadata source, count and active stat
|
|||||||
assert.match(tabs[0]?.subtitle ?? "", /trace trc_done/u);
|
assert.match(tabs[0]?.subtitle ?? "", /trace trc_done/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("R2 session tab labels normalize object previews and reject object placeholders", () => {
|
||||||
|
const tabs = sortSessionTabs([
|
||||||
|
{ conversationId: "cnv_object", sessionId: "ses_object", firstUserMessagePreview: { text: "对象预览标题" } as unknown as string, updatedAt: "2026-01-03T00:00:00.000Z" },
|
||||||
|
{ conversationId: "cnv_literal", sessionId: "ses_literal", firstUserMessagePreview: "[object Object]", updatedAt: "2026-01-02T00:00:00.000Z" }
|
||||||
|
], null);
|
||||||
|
assert.equal(tabs[0]?.label, "对象预览标题");
|
||||||
|
assert.equal(tabs[1]?.label, "Session ses_lite...");
|
||||||
|
assert.ok(tabs.every((tab) => !tab.label.includes("[object Object]")));
|
||||||
|
});
|
||||||
|
|
||||||
test("R2 provider options follow catalog and keep selected fallback", () => {
|
test("R2 provider options follow catalog and keep selected fallback", () => {
|
||||||
const catalog = providerProfileOptionsFromPayload({ profiles: [{ profile: "codex-api", configured: true }, { profile: "deepseek", configured: false }] }, "minimax-m3");
|
const catalog = providerProfileOptionsFromPayload({ profiles: [{ profile: "codex-api", configured: true }, { profile: "deepseek", configured: false }] }, "minimax-m3");
|
||||||
assert.deepEqual(catalog.map((item) => item.value), ["deepseek", "codex-api", "minimax-m3"]);
|
assert.deepEqual(catalog.map((item) => item.value), ["deepseek", "codex-api", "minimax-m3"]);
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function readWidth(): number {
|
|||||||
<div class="session-model-channel" id="session-model-channel">模型通道:{{ workbench.providerProfile }}</div>
|
<div class="session-model-channel" id="session-model-channel">模型通道:{{ workbench.providerProfile }}</div>
|
||||||
<div class="session-list" id="session-tabs">
|
<div class="session-list" id="session-tabs">
|
||||||
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" type="button" @click="workbench.selectConversation(tab)">
|
<button v-for="tab in workbench.sessionTabs" :key="tab.conversationId" class="session-tab" :data-active="tab.active" type="button" @click="workbench.selectConversation(tab)">
|
||||||
<strong>{{ tab.label || tab.title || tab.name || tab.firstUserMessagePreview || tab.conversationId }}</strong>
|
<strong>{{ tab.label || tab.conversationId }}</strong>
|
||||||
<span>{{ tab.sessionId || "未绑定 session" }}</span>
|
<span>{{ tab.sessionId || "未绑定 session" }}</span>
|
||||||
<span>{{ tab.subtitle }}</span>
|
<span>{{ tab.subtitle }}</span>
|
||||||
<small>{{ formatRelativeTime(tab.updatedAt || tab.startedAt) }}</small>
|
<small>{{ formatRelativeTime(tab.updatedAt || tab.startedAt) }}</small>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface ProviderProfileOption {
|
|||||||
|
|
||||||
export const RECENT_DRAFTS_STORAGE_KEY = "hwlab.workbench.recentDrafts.v1";
|
export const RECENT_DRAFTS_STORAGE_KEY = "hwlab.workbench.recentDrafts.v1";
|
||||||
export const RECENT_DRAFTS_LIMIT = 8;
|
export const RECENT_DRAFTS_LIMIT = 8;
|
||||||
|
const OBJECT_OBJECT_TEXT = "[object Object]";
|
||||||
|
|
||||||
const BUILTIN_PROVIDER_PROFILE_ORDER = Object.freeze(["deepseek", "dsflash-go", "codex-api", "minimax-m3"]);
|
const BUILTIN_PROVIDER_PROFILE_ORDER = Object.freeze(["deepseek", "dsflash-go", "codex-api", "minimax-m3"]);
|
||||||
const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly<Record<string, string>> = Object.freeze({
|
const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly<Record<string, string>> = Object.freeze({
|
||||||
@@ -91,7 +92,17 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ
|
|||||||
const updatedAt = firstNonEmptyString(conversation.updatedAt, conversation.snapshot?.updatedAt, conversation.startedAt);
|
const updatedAt = firstNonEmptyString(conversation.updatedAt, conversation.snapshot?.updatedAt, conversation.startedAt);
|
||||||
const status = firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, conversation.messages?.at(-1)?.status) ?? "source";
|
const status = firstNonEmptyString(conversation.snapshot?.sessionStatus, conversation.status, conversation.messages?.at(-1)?.status) ?? "source";
|
||||||
const trace = firstNonEmptyString(conversation.lastTraceId, conversation.messages?.at(-1)?.traceId);
|
const trace = firstNonEmptyString(conversation.lastTraceId, conversation.messages?.at(-1)?.traceId);
|
||||||
const preview = firstNonEmptyString(conversation.firstUserMessagePreview, conversation.snapshot?.firstUserMessagePreview, conversation.messages?.find((message) => message.role === "user")?.text);
|
const userMessage = conversation.messages?.find((message) => message.role === "user");
|
||||||
|
const preview = firstReadableText(
|
||||||
|
conversation.firstUserMessagePreview,
|
||||||
|
conversation.snapshot?.firstUserMessagePreview,
|
||||||
|
conversation.userPreview,
|
||||||
|
conversation.title,
|
||||||
|
conversation.name,
|
||||||
|
userMessage?.text,
|
||||||
|
userMessage?.content,
|
||||||
|
userMessage?.title
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...conversation,
|
...conversation,
|
||||||
key: sessionId ?? conversationId,
|
key: sessionId ?? conversationId,
|
||||||
@@ -207,9 +218,40 @@ function compareProviderProfileOptions(left: ProviderProfileOption, right: Provi
|
|||||||
function truncateLabel(value: string | null): string | null {
|
function truncateLabel(value: string | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const text = value.replace(/\s+/gu, " ").trim();
|
const text = value.replace(/\s+/gu, " ").trim();
|
||||||
|
if (!text || text === OBJECT_OBJECT_TEXT) return null;
|
||||||
return text.length <= 36 ? text : `${text.slice(0, 36).trimEnd()}...`;
|
return text.length <= 36 ? text : `${text.slice(0, 36).trimEnd()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstReadableText(...values: unknown[]): string | null {
|
||||||
|
for (const value of values) {
|
||||||
|
const text = readableText(value);
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readableText(value: unknown): string | null {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const text = value.replace(/\s+/gu, " ").trim();
|
||||||
|
return text && text !== OBJECT_OBJECT_TEXT ? text : null;
|
||||||
|
}
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
if (Array.isArray(value)) return firstReadableText(...value);
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return firstReadableText(
|
||||||
|
record.text,
|
||||||
|
record.content,
|
||||||
|
record.message,
|
||||||
|
record.summary,
|
||||||
|
record.preview,
|
||||||
|
record.prompt,
|
||||||
|
record.title,
|
||||||
|
record.name,
|
||||||
|
record.markdown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function shortToken(value: string | null | undefined, size: number): string {
|
function shortToken(value: string | null | undefined, size: number): string {
|
||||||
const text = String(value ?? "");
|
const text = String(value ?? "");
|
||||||
if (text.length <= size) return text;
|
if (text.length <= size) return text;
|
||||||
|
|||||||
Reference in New Issue
Block a user