@mtharrison/loupe 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/app.css +636 -115
- package/dist/client/app.js +3342 -18181
- package/dist/client/chunk-FF2MKFR7.js +1318 -0
- package/dist/client/markdown-block-DMQHS3E5.js +14377 -0
- package/dist/session-nav.d.ts +34 -0
- package/dist/session-nav.js +132 -0
- package/dist/store.js +4 -1
- package/dist/types.d.ts +23 -0
- package/dist/ui-build.js +1 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +173 -0
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type SessionNavHierarchyNode = {
|
|
2
|
+
children: SessionNavHierarchyNode[];
|
|
3
|
+
count: number;
|
|
4
|
+
id: string;
|
|
5
|
+
meta: Record<string, any>;
|
|
6
|
+
traceIds: string[];
|
|
7
|
+
type: string;
|
|
8
|
+
};
|
|
9
|
+
export type SessionNavTraceSummary = {
|
|
10
|
+
costUsd: number | null;
|
|
11
|
+
flags?: {
|
|
12
|
+
hasHighlights: boolean;
|
|
13
|
+
};
|
|
14
|
+
hierarchy: {
|
|
15
|
+
rootActorId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
};
|
|
18
|
+
id: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
status: "pending" | "ok" | "error";
|
|
21
|
+
};
|
|
22
|
+
export type SessionNavItem = {
|
|
23
|
+
callCount: number;
|
|
24
|
+
costUsd: number | null;
|
|
25
|
+
hasHighlights: boolean;
|
|
26
|
+
id: string;
|
|
27
|
+
latestStartedAt: string | null;
|
|
28
|
+
latestTimestamp: string | null;
|
|
29
|
+
primaryActor: string;
|
|
30
|
+
primaryLabel: string;
|
|
31
|
+
shortSessionId: string;
|
|
32
|
+
status: "error" | "ok" | "pending";
|
|
33
|
+
};
|
|
34
|
+
export declare function deriveSessionNavItems(sessionNodes: SessionNavHierarchyNode[], traceById: Map<string, SessionNavTraceSummary>): SessionNavItem[];
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deriveSessionNavItems = deriveSessionNavItems;
|
|
4
|
+
function deriveSessionNavItems(sessionNodes, traceById) {
|
|
5
|
+
return sessionNodes
|
|
6
|
+
.map((node) => deriveSessionNavItem(node, traceById))
|
|
7
|
+
.sort(compareSessionNavItems);
|
|
8
|
+
}
|
|
9
|
+
function deriveSessionNavItem(node, traceById) {
|
|
10
|
+
const traces = node.traceIds
|
|
11
|
+
.map((traceId) => traceById.get(traceId))
|
|
12
|
+
.filter((trace) => Boolean(trace));
|
|
13
|
+
const latestTrace = getLatestTrace(traces);
|
|
14
|
+
const sessionId = getSessionId(node) ?? latestTrace?.hierarchy.sessionId ?? "unknown";
|
|
15
|
+
const shortSessionId = shortId(sessionId);
|
|
16
|
+
const primaryActor = getPrimaryActor(node) ?? latestTrace?.hierarchy.rootActorId ?? shortSessionId;
|
|
17
|
+
return {
|
|
18
|
+
callCount: node.count,
|
|
19
|
+
costUsd: getCostUsd(node, traces),
|
|
20
|
+
hasHighlights: traces.some((trace) => Boolean(trace.flags?.hasHighlights)),
|
|
21
|
+
id: node.id,
|
|
22
|
+
latestStartedAt: latestTrace?.startedAt ?? null,
|
|
23
|
+
latestTimestamp: latestTrace?.startedAt
|
|
24
|
+
? formatCompactTimestamp(latestTrace.startedAt)
|
|
25
|
+
: null,
|
|
26
|
+
primaryActor,
|
|
27
|
+
primaryLabel: primaryActor,
|
|
28
|
+
shortSessionId,
|
|
29
|
+
status: getAggregateStatus(traces),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function getCostUsd(node, traces) {
|
|
33
|
+
if (typeof node.meta?.costUsd === "number" &&
|
|
34
|
+
Number.isFinite(node.meta.costUsd)) {
|
|
35
|
+
return roundCostUsd(node.meta.costUsd);
|
|
36
|
+
}
|
|
37
|
+
const traceCosts = traces
|
|
38
|
+
.map((trace) => trace.costUsd)
|
|
39
|
+
.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
40
|
+
if (!traceCosts.length) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return roundCostUsd(traceCosts.reduce((sum, value) => sum + value, 0));
|
|
44
|
+
}
|
|
45
|
+
function getLatestTrace(traces) {
|
|
46
|
+
let latestTrace = null;
|
|
47
|
+
let latestTimestamp = Number.NEGATIVE_INFINITY;
|
|
48
|
+
for (const trace of traces) {
|
|
49
|
+
const nextTimestamp = Date.parse(trace.startedAt);
|
|
50
|
+
if (!Number.isFinite(nextTimestamp)) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (nextTimestamp > latestTimestamp) {
|
|
54
|
+
latestTrace = trace;
|
|
55
|
+
latestTimestamp = nextTimestamp;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return latestTrace;
|
|
59
|
+
}
|
|
60
|
+
function getAggregateStatus(traces) {
|
|
61
|
+
if (traces.some((trace) => trace.status === "error")) {
|
|
62
|
+
return "error";
|
|
63
|
+
}
|
|
64
|
+
if (traces.some((trace) => trace.status === "pending")) {
|
|
65
|
+
return "pending";
|
|
66
|
+
}
|
|
67
|
+
return "ok";
|
|
68
|
+
}
|
|
69
|
+
function getSessionId(node) {
|
|
70
|
+
if (typeof node.meta?.sessionId === "string" && node.meta.sessionId) {
|
|
71
|
+
return node.meta.sessionId;
|
|
72
|
+
}
|
|
73
|
+
if (node.id.startsWith("session:")) {
|
|
74
|
+
return node.id.slice("session:".length);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function getPrimaryActor(node) {
|
|
79
|
+
const actorNode = node.children.find((child) => child.type === "actor");
|
|
80
|
+
return (actorNode?.meta?.actorId ??
|
|
81
|
+
actorNode?.meta?.rootActorId ??
|
|
82
|
+
node.meta?.rootActorId ??
|
|
83
|
+
null);
|
|
84
|
+
}
|
|
85
|
+
function compareSessionNavItems(left, right) {
|
|
86
|
+
const timestampDelta = toSortableTimestamp(right.latestStartedAt) -
|
|
87
|
+
toSortableTimestamp(left.latestStartedAt);
|
|
88
|
+
if (timestampDelta !== 0) {
|
|
89
|
+
return timestampDelta;
|
|
90
|
+
}
|
|
91
|
+
const statusDelta = getStatusRank(left.status) - getStatusRank(right.status);
|
|
92
|
+
if (statusDelta !== 0) {
|
|
93
|
+
return statusDelta;
|
|
94
|
+
}
|
|
95
|
+
const costDelta = (right.costUsd ?? 0) - (left.costUsd ?? 0);
|
|
96
|
+
if (costDelta !== 0) {
|
|
97
|
+
return costDelta;
|
|
98
|
+
}
|
|
99
|
+
return left.primaryLabel.localeCompare(right.primaryLabel);
|
|
100
|
+
}
|
|
101
|
+
function getStatusRank(status) {
|
|
102
|
+
switch (status) {
|
|
103
|
+
case "error":
|
|
104
|
+
return 0;
|
|
105
|
+
case "pending":
|
|
106
|
+
return 1;
|
|
107
|
+
default:
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function toSortableTimestamp(value) {
|
|
112
|
+
if (!value) {
|
|
113
|
+
return Number.NEGATIVE_INFINITY;
|
|
114
|
+
}
|
|
115
|
+
const timestamp = Date.parse(value);
|
|
116
|
+
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
|
|
117
|
+
}
|
|
118
|
+
function roundCostUsd(value) {
|
|
119
|
+
return Math.round(value * 1e12) / 1e12;
|
|
120
|
+
}
|
|
121
|
+
function shortId(value) {
|
|
122
|
+
if (!value) {
|
|
123
|
+
return "unknown";
|
|
124
|
+
}
|
|
125
|
+
return value.length > 8 ? value.slice(0, 8) : value;
|
|
126
|
+
}
|
|
127
|
+
function formatCompactTimestamp(value) {
|
|
128
|
+
return new Date(value).toLocaleTimeString([], {
|
|
129
|
+
hour: "2-digit",
|
|
130
|
+
minute: "2-digit",
|
|
131
|
+
});
|
|
132
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -220,7 +220,10 @@ class TraceStore extends node_events_1.EventEmitter {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
cloneTrace(trace) {
|
|
223
|
-
return
|
|
223
|
+
return {
|
|
224
|
+
...(0, utils_1.safeClone)(trace),
|
|
225
|
+
insights: (0, utils_1.getTraceInsights)(trace),
|
|
226
|
+
};
|
|
224
227
|
}
|
|
225
228
|
filteredTraces(filters = {}) {
|
|
226
229
|
const tagFilters = normalizeTagFilters(filters.tags || filters.tagFilters);
|
package/dist/types.d.ts
CHANGED
|
@@ -79,6 +79,27 @@ export type TraceRequest = {
|
|
|
79
79
|
input?: Record<string, any>;
|
|
80
80
|
options?: Record<string, any>;
|
|
81
81
|
};
|
|
82
|
+
export type TraceStructuredInputInsight = {
|
|
83
|
+
format: 'xml';
|
|
84
|
+
role: string;
|
|
85
|
+
snippet: string;
|
|
86
|
+
tags: string[];
|
|
87
|
+
};
|
|
88
|
+
export type TraceHighlightInsight = {
|
|
89
|
+
description: string;
|
|
90
|
+
kind: string;
|
|
91
|
+
snippet: string;
|
|
92
|
+
source: string;
|
|
93
|
+
title: string;
|
|
94
|
+
};
|
|
95
|
+
export type TraceInsights = {
|
|
96
|
+
highlights: TraceHighlightInsight[];
|
|
97
|
+
structuredInputs: TraceStructuredInputInsight[];
|
|
98
|
+
};
|
|
99
|
+
export type TraceSummaryFlags = {
|
|
100
|
+
hasHighlights: boolean;
|
|
101
|
+
hasStructuredInput: boolean;
|
|
102
|
+
};
|
|
82
103
|
export type TraceRecord = {
|
|
83
104
|
context: NormalizedTraceContext;
|
|
84
105
|
endedAt: string | null;
|
|
@@ -106,6 +127,7 @@ export type TraceRecord = {
|
|
|
106
127
|
usage: Record<string, any> | null;
|
|
107
128
|
};
|
|
108
129
|
};
|
|
130
|
+
insights?: TraceInsights;
|
|
109
131
|
tags: TraceTags;
|
|
110
132
|
usage: Record<string, any> | null;
|
|
111
133
|
};
|
|
@@ -113,6 +135,7 @@ export type TraceSummary = {
|
|
|
113
135
|
costUsd: number | null;
|
|
114
136
|
durationMs: number | null;
|
|
115
137
|
endedAt: string | null;
|
|
138
|
+
flags?: TraceSummaryFlags;
|
|
116
139
|
hierarchy: TraceHierarchy;
|
|
117
140
|
id: string;
|
|
118
141
|
kind: string;
|
package/dist/ui-build.js
CHANGED
package/dist/utils.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type NormalizedTraceContext, type TraceContext, type TraceMode, type TraceRecord, type TraceSummary } from './types';
|
|
1
|
+
import { type NormalizedTraceContext, type TraceContext, type TraceInsights, type TraceMode, type TraceRecord, type TraceSummary } from './types';
|
|
2
2
|
export declare function safeClone<T>(value: T): T;
|
|
3
3
|
export declare function toErrorPayload(error: any): Record<string, any> | null;
|
|
4
4
|
export declare function sanitizeHeaders(headers: Record<string, any> | undefined): Record<string, any>;
|
|
@@ -7,4 +7,5 @@ export declare function stringifyRecord(record: Record<string, any>): Record<str
|
|
|
7
7
|
export declare function normalizeTraceContext(context: TraceContext | undefined, mode: TraceMode): NormalizedTraceContext;
|
|
8
8
|
export declare function summariseValue(value: unknown, maxLength?: number): string;
|
|
9
9
|
export declare function getUsageCostUsd(usage: Record<string, any> | null | undefined): number | null;
|
|
10
|
+
export declare function getTraceInsights(trace: TraceRecord): TraceInsights;
|
|
10
11
|
export declare function toSummary(trace: TraceRecord): TraceSummary;
|
package/dist/utils.js
CHANGED
|
@@ -8,6 +8,7 @@ exports.stringifyRecord = stringifyRecord;
|
|
|
8
8
|
exports.normalizeTraceContext = normalizeTraceContext;
|
|
9
9
|
exports.summariseValue = summariseValue;
|
|
10
10
|
exports.getUsageCostUsd = getUsageCostUsd;
|
|
11
|
+
exports.getTraceInsights = getTraceInsights;
|
|
11
12
|
exports.toSummary = toSummary;
|
|
12
13
|
function safeClone(value) {
|
|
13
14
|
if (value === undefined) {
|
|
@@ -253,12 +254,84 @@ function extractResponsePreview(trace) {
|
|
|
253
254
|
}
|
|
254
255
|
return '';
|
|
255
256
|
}
|
|
257
|
+
function getTraceInsights(trace) {
|
|
258
|
+
const structuredInputs = [];
|
|
259
|
+
const highlights = [];
|
|
260
|
+
const seenStructured = new Set();
|
|
261
|
+
const seenHighlights = new Set();
|
|
262
|
+
for (const message of collectTraceMessages(trace)) {
|
|
263
|
+
const text = toInsightText(message.content);
|
|
264
|
+
if (!text) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const structuredMarkup = detectStructuredMarkup(text);
|
|
268
|
+
if (structuredMarkup) {
|
|
269
|
+
const structuredKey = `${message.source}:${structuredMarkup.tags.join('|')}:${structuredMarkup.snippet}`;
|
|
270
|
+
if (!seenStructured.has(structuredKey)) {
|
|
271
|
+
structuredInputs.push({
|
|
272
|
+
format: 'xml',
|
|
273
|
+
role: message.role,
|
|
274
|
+
snippet: structuredMarkup.snippet,
|
|
275
|
+
tags: structuredMarkup.tags,
|
|
276
|
+
});
|
|
277
|
+
seenStructured.add(structuredKey);
|
|
278
|
+
}
|
|
279
|
+
const highlightKey = `structured:${message.source}:${structuredMarkup.tags.join('|')}`;
|
|
280
|
+
if (!seenHighlights.has(highlightKey)) {
|
|
281
|
+
highlights.push({
|
|
282
|
+
kind: 'structured-input',
|
|
283
|
+
title: `Structured ${message.role} input`,
|
|
284
|
+
description: `Contains XML-like markup (${structuredMarkup.tags.slice(0, 3).join(', ')}) that may influence guardrail behavior.`,
|
|
285
|
+
source: message.source,
|
|
286
|
+
snippet: structuredMarkup.snippet,
|
|
287
|
+
});
|
|
288
|
+
seenHighlights.add(highlightKey);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if ((message.role === 'user' || message.role === 'system') && looksLikeLongPrompt(text)) {
|
|
292
|
+
const longKey = `long:${message.source}`;
|
|
293
|
+
if (!seenHighlights.has(longKey)) {
|
|
294
|
+
highlights.push({
|
|
295
|
+
kind: 'long-message',
|
|
296
|
+
title: `Long ${message.role} message`,
|
|
297
|
+
description: 'Large prompt payloads often hide embedded instructions, policies, or contextual data worth inspecting.',
|
|
298
|
+
source: message.source,
|
|
299
|
+
snippet: createSnippet(text, 240),
|
|
300
|
+
});
|
|
301
|
+
seenHighlights.add(longKey);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (trace.kind === 'guardrail') {
|
|
306
|
+
const contextSummary = [trace.context.guardrailPhase, trace.context.guardrailType, trace.context.systemType]
|
|
307
|
+
.filter(Boolean)
|
|
308
|
+
.join(' / ');
|
|
309
|
+
if (contextSummary) {
|
|
310
|
+
highlights.push({
|
|
311
|
+
kind: 'guardrail-context',
|
|
312
|
+
title: `${capitalize(trace.context.guardrailPhase || 'guardrail')} guardrail context`,
|
|
313
|
+
description: `This trace ran inside ${contextSummary}.`,
|
|
314
|
+
source: 'trace:guardrail',
|
|
315
|
+
snippet: createSnippet(contextSummary, 180),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
structuredInputs,
|
|
321
|
+
highlights,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
256
324
|
function toSummary(trace) {
|
|
257
325
|
const durationMs = trace.endedAt ? Math.max(0, Date.parse(trace.endedAt) - Date.parse(trace.startedAt)) : null;
|
|
326
|
+
const insights = trace.insights || getTraceInsights(trace);
|
|
258
327
|
return {
|
|
259
328
|
costUsd: getUsageCostUsd(trace.usage),
|
|
260
329
|
durationMs,
|
|
261
330
|
endedAt: trace.endedAt,
|
|
331
|
+
flags: {
|
|
332
|
+
hasHighlights: insights.highlights.length > 0,
|
|
333
|
+
hasStructuredInput: insights.structuredInputs.length > 0,
|
|
334
|
+
},
|
|
262
335
|
hierarchy: safeClone(trace.hierarchy),
|
|
263
336
|
id: trace.id,
|
|
264
337
|
kind: trace.kind,
|
|
@@ -278,3 +351,103 @@ function toSummary(trace) {
|
|
|
278
351
|
tags: safeClone(trace.tags),
|
|
279
352
|
};
|
|
280
353
|
}
|
|
354
|
+
function collectTraceMessages(trace) {
|
|
355
|
+
const entries = [];
|
|
356
|
+
const requestMessages = trace.request?.input?.messages;
|
|
357
|
+
if (Array.isArray(requestMessages)) {
|
|
358
|
+
for (const [index, message] of requestMessages.entries()) {
|
|
359
|
+
entries.push({
|
|
360
|
+
content: message?.content,
|
|
361
|
+
role: typeof message?.role === 'string' ? message.role : 'unknown',
|
|
362
|
+
source: `request:${index}`,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const responseMessage = trace.response?.message || trace.stream?.reconstructed?.message;
|
|
367
|
+
if (responseMessage?.content !== undefined) {
|
|
368
|
+
entries.push({
|
|
369
|
+
content: responseMessage.content,
|
|
370
|
+
role: typeof responseMessage.role === 'string' ? responseMessage.role : 'assistant',
|
|
371
|
+
source: 'response:0',
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return entries;
|
|
375
|
+
}
|
|
376
|
+
function toInsightText(content) {
|
|
377
|
+
if (typeof content === 'string') {
|
|
378
|
+
const trimmed = content.trim();
|
|
379
|
+
return trimmed ? trimmed : null;
|
|
380
|
+
}
|
|
381
|
+
if (Array.isArray(content)) {
|
|
382
|
+
const parts = content
|
|
383
|
+
.map((item) => {
|
|
384
|
+
if (typeof item === 'string') {
|
|
385
|
+
return item;
|
|
386
|
+
}
|
|
387
|
+
if (typeof item?.text === 'string') {
|
|
388
|
+
return item.text;
|
|
389
|
+
}
|
|
390
|
+
if (typeof item?.text?.value === 'string') {
|
|
391
|
+
return item.text.value;
|
|
392
|
+
}
|
|
393
|
+
if (typeof item?.content === 'string') {
|
|
394
|
+
return item.content;
|
|
395
|
+
}
|
|
396
|
+
return '';
|
|
397
|
+
})
|
|
398
|
+
.filter(Boolean);
|
|
399
|
+
return parts.length ? parts.join('\n\n').trim() : null;
|
|
400
|
+
}
|
|
401
|
+
if (typeof content?.text === 'string') {
|
|
402
|
+
return content.text.trim() || null;
|
|
403
|
+
}
|
|
404
|
+
if (typeof content?.text?.value === 'string') {
|
|
405
|
+
return content.text.value.trim() || null;
|
|
406
|
+
}
|
|
407
|
+
if (typeof content?.content === 'string') {
|
|
408
|
+
return content.content.trim() || null;
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
function detectStructuredMarkup(text) {
|
|
413
|
+
const tagRegex = /<\/?([A-Za-z][\w:-]*)\b[^>]*>/g;
|
|
414
|
+
const tagNames = [];
|
|
415
|
+
let match = tagRegex.exec(text);
|
|
416
|
+
while (match) {
|
|
417
|
+
tagNames.push(match[1].toLowerCase());
|
|
418
|
+
match = tagRegex.exec(text);
|
|
419
|
+
}
|
|
420
|
+
const uniqueTags = [...new Set(tagNames)];
|
|
421
|
+
if (!uniqueTags.length) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
const hasInstructionalTag = uniqueTags.some((tag) => ['assistant', 'instruction', 'option', 'policy', 'system', 'user'].includes(tag));
|
|
425
|
+
const hasMultipleTags = uniqueTags.length >= 2;
|
|
426
|
+
const hasClosingTag = /<\/[A-Za-z][\w:-]*>/.test(text);
|
|
427
|
+
if (!hasClosingTag && !hasInstructionalTag && !hasMultipleTags) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
snippet: createSnippet(text, 220),
|
|
432
|
+
tags: uniqueTags.slice(0, 6),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function looksLikeLongPrompt(text) {
|
|
436
|
+
return text.length >= 1200 || countLines(text) >= 20;
|
|
437
|
+
}
|
|
438
|
+
function countLines(text) {
|
|
439
|
+
return text.split(/\r?\n/).length;
|
|
440
|
+
}
|
|
441
|
+
function createSnippet(text, maxLength) {
|
|
442
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
443
|
+
if (trimmed.length <= maxLength) {
|
|
444
|
+
return trimmed;
|
|
445
|
+
}
|
|
446
|
+
return `${trimmed.slice(0, maxLength - 1)}...`;
|
|
447
|
+
}
|
|
448
|
+
function capitalize(value) {
|
|
449
|
+
if (!value) {
|
|
450
|
+
return '';
|
|
451
|
+
}
|
|
452
|
+
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
|
453
|
+
}
|