The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit faf8a393d637Commit detailsView latest

apps/api/src/agent-card-image.ts 305 lines · 7.7 KB · typescript
1
import { readFileSync } from 'node:fs';
2
import { createRequire } from 'node:module';
3
import { dirname, join } from 'node:path';
4
import { createElement, type CSSProperties, type ReactNode } from 'react';
5
import satori from 'satori';
6
import sharp from 'sharp';
7
import type { PublicAgentCard } from '../../../packages/protocol/src/index.js';
8
9
const shorten = (value: string, length: number) =>
10
  value.length > length ? `${value.slice(0, length - 1).trimEnd()}…` : value;
11
12
interface CapabilityChip {
13
  label: string;
14
  width: number;
15
  x: number;
16
}
17
18
function capabilityChips(capabilities: string[]): CapabilityChip[] {
19
  let x = 64;
20
  return capabilities.slice(0, 4).map((capability) => {
21
    const label = shorten(capability, 25);
22
    const width = Math.min(250, Math.max(96, label.length * 10 + 32));
23
    const chip = { label, width, x };
24
    x += width + 12;
25
    return chip;
26
  });
27
}
28
29
function descriptionLines(value: string) {
30
  const words = value.trim().split(/\s+/);
31
  const lines: string[] = [];
32
  for (const word of words) {
33
    const current = lines.at(-1);
34
    if (!current || (current.length + word.length + 1 > 66 && lines.length < 2)) lines.push(word);
35
    else lines[lines.length - 1] = `${current} ${word}`;
36
  }
37
  if (lines.length > 2) lines.length = 2;
38
  if (lines[1] && lines[1].length > 66) lines[1] = shorten(lines[1], 66);
39
  return lines;
40
}
41
42
const absolute = (style: CSSProperties): CSSProperties => ({
43
  position: 'absolute',
44
  display: 'flex',
45
  ...style,
46
});
47
48
let geistFonts: Array<{
49
  name: string;
50
  data: Buffer;
51
  weight: 400 | 700;
52
  style: 'normal';
53
}> | null = null;
54
55
function loadGeistFonts() {
56
  if (geistFonts) return geistFonts;
57
  const require = createRequire(import.meta.url);
58
  const fontDirectory = join(dirname(require.resolve('geist/font/sans')), 'fonts/geist-sans');
59
  geistFonts = [
60
    {
61
      name: 'Geist',
62
      data: readFileSync(join(fontDirectory, 'Geist-Regular.ttf')),
63
      weight: 400,
64
      style: 'normal',
65
    },
66
    {
67
      name: 'Geist',
68
      data: readFileSync(join(fontDirectory, 'Geist-Bold.ttf')),
69
      weight: 700,
70
      style: 'normal',
71
    },
72
  ];
73
  return geistFonts;
74
}
75
76
async function renderTextLayer({
77
  card,
78
  chips,
79
  description,
80
  initials,
81
  name,
82
  nameSize,
83
  publicReference,
84
}: {
85
  card: PublicAgentCard;
86
  chips: CapabilityChip[];
87
  description: string[];
88
  initials: string;
89
  name: string;
90
  nameSize: number;
91
  publicReference: string;
92
}) {
93
  const children: ReactNode[] = [
94
    createElement(
95
      'div',
96
      {
97
        key: 'brand',
98
        style: absolute({
99
          left: 108,
100
          top: 58,
101
          color: '#f04b2d',
102
          fontSize: 27,
103
          fontWeight: 700,
104
          letterSpacing: -1,
105
          lineHeight: 1,
106
        }),
107
      },
108
      'OpenClasp',
109
    ),
110
    createElement(
111
      'div',
112
      {
113
        key: 'verified',
114
        style: absolute({
115
          left: 978,
116
          top: 69,
117
          color: '#44d37e',
118
          fontSize: 16,
119
          lineHeight: 1,
120
        }),
121
      },
122
      'Publisher verified',
123
    ),
124
    createElement(
125
      'div',
126
      {
127
        key: 'initials',
128
        style: absolute({
129
          left: 64,
130
          top: 165,
131
          width: 122,
132
          height: 122,
133
          alignItems: 'center',
134
          justifyContent: 'center',
135
          color: '#ff8068',
136
          fontSize: 42,
137
          fontWeight: 700,
138
        }),
139
      },
140
      initials || 'AI',
141
    ),
142
    createElement(
143
      'div',
144
      {
145
        key: 'framework',
146
        style: absolute({
147
          left: 220,
148
          top: 160,
149
          color: '#a99e99',
150
          fontSize: 18,
151
          letterSpacing: 2,
152
          lineHeight: 1,
153
        }),
154
      },
155
      `${shorten(card.framework, 44).toUpperCase()} · AGENT CARD`,
156
    ),
157
    createElement(
158
      'div',
159
      {
160
        key: 'name',
161
        style: absolute({
162
          left: 220,
163
          top: 196,
164
          color: '#f7f2ef',
165
          fontSize: nameSize,
166
          fontWeight: 700,
167
          letterSpacing: -2,
168
          lineHeight: 1,
169
        }),
170
      },
171
      name,
172
    ),
173
    ...description.map((line, index) =>
174
      createElement(
175
        'div',
176
        {
177
          key: `description-${index}`,
178
          style: absolute({
179
            left: 220,
180
            top: 287 + index * 34,
181
            color: '#c7bdb8',
182
            fontSize: 24,
183
            lineHeight: 1,
184
          }),
185
        },
186
        line,
187
      ),
188
    ),
189
    ...chips.map((chip) =>
190
      createElement(
191
        'div',
192
        {
193
          key: `chip-${chip.x}`,
194
          style: absolute({
195
            left: chip.x + 16,
196
            top: 442,
197
            color: '#ddd4d0',
198
            fontSize: 17,
199
            lineHeight: 1,
200
          }),
201
        },
202
        chip.label,
203
      ),
204
    ),
205
    createElement(
206
      'div',
207
      {
208
        key: 'reference',
209
        style: absolute({
210
          left: 64,
211
          top: 568,
212
          color: '#8f8580',
213
          fontSize: 16,
214
          lineHeight: 1,
215
        }),
216
      },
217
      publicReference,
218
    ),
219
    createElement(
220
      'div',
221
      {
222
        key: 'disclaimer',
223
        style: absolute({
224
          right: 64,
225
          top: 568,
226
          color: '#8f8580',
227
          fontSize: 16,
228
          lineHeight: 1,
229
        }),
230
      },
231
      'Capabilities are self-declared',
232
    ),
233
  ];
234
235
  return satori(
236
    createElement(
237
      'div',
238
      {
239
        style: {
240
          position: 'relative',
241
          display: 'flex',
242
          width: 1200,
243
          height: 630,
244
          fontFamily: 'Geist',
245
        },
246
      },
247
      ...children,
248
    ),
249
    { width: 1200, height: 630, fonts: loadGeistFonts() },
250
  );
251
}
252
253
export async function renderAgentCardImage(card: PublicAgentCard) {
254
  const initials = card.name
255
    .split(/\s+/)
256
    .filter(Boolean)
257
    .slice(0, 2)
258
    .map((part) => part[0]?.toUpperCase())
259
    .join('');
260
  const publicReference = shorten(card.profileUrl ?? card.cardUrl, 88);
261
  const name = shorten(card.name, 42);
262
  const nameSize = name.length > 30 ? 50 : 62;
263
  const description = descriptionLines(card.description || 'Public agent identity on OpenClasp');
264
  const chips = capabilityChips(card.capabilities);
265
  const textLayer = await renderTextLayer({
266
    card,
267
    chips,
268
    description,
269
    initials,
270
    name,
271
    nameSize,
272
    publicReference,
273
  });
274
  const textContent = textLayer.slice(textLayer.indexOf('>') + 1, textLayer.lastIndexOf('</svg>'));
275
  const chipBackgrounds = chips
276
    .map(
277
      ({ width, x }) =>
278
        `<rect x="${x}" y="430" width="${width}" height="42" rx="8" fill="#ffffff0b" stroke="#ffffff28"/>`,
279
    )
280
    .join('');
281
  const svg = `<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
282
    <defs>
283
      <linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
284
        <stop offset="0" stop-color="#0c0a0a"/>
285
        <stop offset="0.62" stop-color="#17100e"/>
286
        <stop offset="1" stop-color="#2a120d"/>
287
      </linearGradient>
288
      <radialGradient id="glow" cx="0" cy="0" r="1" gradientTransform="translate(1030 120) rotate(140) scale(430 340)">
289
        <stop stop-color="#f04b2d" stop-opacity="0.2"/>
290
        <stop offset="1" stop-color="#f04b2d" stop-opacity="0"/>
291
      </radialGradient>
292
    </defs>
293
    <rect width="1200" height="630" fill="url(#background)"/>
294
    <rect width="1200" height="630" fill="url(#glow)"/>
295
    <path d="M64 66h18l10 10-10 10H64l-10-10 10-10Z" fill="none" stroke="#f04b2d" stroke-width="4"/>
296
    <rect x="936" y="58" width="200" height="42" rx="21" fill="#44d37e0d" stroke="#44d37e88"/>
297
    <path d="M954 79l5 5 9-11" fill="none" stroke="#44d37e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
298
    <rect x="64" y="165" width="122" height="122" rx="28" fill="#f04b2d1a" stroke="#f04b2d80"/>
299
    ${chipBackgrounds}
300
    <line x1="64" y1="540" x2="1136" y2="540" stroke="#ffffff21"/>
301
    ${textContent}
302
  </svg>`;
303
304
  return sharp(Buffer.from(svg)).png().toBuffer();
305
}