File size: 3,154 Bytes
7c5ed63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import type { ContentPart, WireEntry } from './agent-record-types';

const BLOBREF_PROTOCOL = 'blobref:';

function isBlobRef(url: string): boolean {
  return url.startsWith(BLOBREF_PROTOCOL);
}

/** Convert a `blobref:<mime>;<hash>` URL into a vis-server blob route.
 *  Non-blobref URLs are returned unchanged. */
export function resolveBlobRefUrl(
  url: string,
  sessionId: string,
  agentId: string,
  baseUrl: string = '',
): string {
  if (!isBlobRef(url)) return url;
  const rest = url.slice(BLOBREF_PROTOCOL.length);
  const semiIdx = rest.indexOf(';');
  if (semiIdx === -1) return url;
  const mimeType = rest.slice(0, semiIdx);
  const hash = rest.slice(semiIdx + 1);
  if (hash.length === 0) return url;
  const path = `/api/sessions/${encodeURIComponent(sessionId)}/blobs/${encodeURIComponent(hash)}?agent=${encodeURIComponent(agentId)}&mime=${encodeURIComponent(mimeType)}`;
  return baseUrl ? `${baseUrl}${path}` : path;
}

/** Walk every record in a wire and replace blobref URLs with vis-server
 *  blob routes so the UI can render them. Only mutates `entry.data`;
 *  `entry.raw` is left untouched. */
export function rehydrateWireEntries(
  entries: readonly WireEntry[],
  sessionId: string,
  agentId: string,
  baseUrl: string = '',
): void {
  for (const entry of entries) {
    rehydrateRecord(entry.data as Record<string, unknown>, sessionId, agentId, baseUrl);
  }
}

function rehydrateRecord(
  record: Record<string, unknown>,
  sessionId: string,
  agentId: string,
  baseUrl: string,
): void {
  const type = record['type'];
  if (type === 'turn.prompt' || type === 'turn.steer') {
    rehydrateParts(record['input'] as unknown as ContentPart[], sessionId, agentId, baseUrl);
    return;
  }
  if (type === 'context.append_message') {
    const message = record['message'] as { content: ContentPart[] };
    rehydrateParts(message.content, sessionId, agentId, baseUrl);
    return;
  }
  if (type === 'context.append_loop_event') {
    const event = record['event'] as Record<string, unknown>;
    if (event['type'] === 'tool.result') {
      const result = event['result'] as Record<string, unknown>;
      if (typeof result['output'] !== 'string') {
        rehydrateParts(result['output'] as ContentPart[], sessionId, agentId, baseUrl);
      }
    } else if (event['type'] === 'content.part') {
      rehydrateParts([event['part'] as ContentPart], sessionId, agentId, baseUrl);
    }
    return;
  }
}

function rehydrateParts(
  parts: ContentPart[],
  sessionId: string,
  agentId: string,
  baseUrl: string,
): void {
  for (const part of parts) {
    switch (part.type) {
      case 'image_url':
        part.imageUrl.url = resolveBlobRefUrl(part.imageUrl.url, sessionId, agentId, baseUrl);
        break;
      case 'audio_url':
        part.audioUrl.url = resolveBlobRefUrl(part.audioUrl.url, sessionId, agentId, baseUrl);
        break;
      case 'video_url':
        part.videoUrl.url = resolveBlobRefUrl(part.videoUrl.url, sessionId, agentId, baseUrl);
        break;
      default:
        break;
    }
  }
}

export function isSafeBlobHash(hash: string): boolean {
  return /^[a-f0-9]{64}$/.test(hash);
}