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

Viewing files at commit 6b91d3cf5293Commit detailsView latest

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