refactor: a2a
This commit is contained in:
+286
-56
@@ -1,5 +1,100 @@
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export interface A2APartText {
|
||||
kind: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface A2APartUrl {
|
||||
kind: "url";
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface A2APartFile {
|
||||
kind: "file";
|
||||
data: string;
|
||||
mediaType?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export type A2APart = A2APartText | A2APartUrl | A2APartFile;
|
||||
|
||||
export interface A2AMessage {
|
||||
messageId?: string;
|
||||
contextId?: string;
|
||||
taskId?: string;
|
||||
role: "user" | "agent" | "system";
|
||||
parts: A2APart[];
|
||||
extensions?: Record<string, unknown>[];
|
||||
referenceTaskIds?: string[];
|
||||
}
|
||||
|
||||
export interface A2AArtifact {
|
||||
artifactId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parts: A2APart[];
|
||||
metadata?: Record<string, unknown>;
|
||||
extensions?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export interface A2ATask {
|
||||
id: string;
|
||||
project_id?: number;
|
||||
context_id?: string;
|
||||
source: string;
|
||||
state: string;
|
||||
remote_agent_id?: number | null;
|
||||
input_text: string;
|
||||
input_parts?: A2APart[];
|
||||
output_text?: string | null;
|
||||
output_parts?: A2APart[];
|
||||
error_message?: string | null;
|
||||
compatibility_mode: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
artifacts?: A2AArtifact[];
|
||||
history?: A2AMessage[];
|
||||
history_length?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
export interface A2AAgentCard {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
provider?: {
|
||||
organization?: string;
|
||||
url?: string;
|
||||
};
|
||||
skills?: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
examples?: string[];
|
||||
inputModes?: string[];
|
||||
outputModes?: string[];
|
||||
securityRequirements?: Array<Record<string, unknown>>;
|
||||
}>;
|
||||
supportedInterfaces?: Array<{
|
||||
type: string;
|
||||
url?: string;
|
||||
protocolBinding?: string;
|
||||
protocolVersion?: string;
|
||||
tenant?: string;
|
||||
}>;
|
||||
defaultInputModes?: string[];
|
||||
defaultOutputModes?: string[];
|
||||
securitySchemes?: Record<string, unknown>;
|
||||
security?: Array<Record<string, unknown>>;
|
||||
signatures?: string[];
|
||||
iconUrl?: string;
|
||||
documentationUrl?: string;
|
||||
}
|
||||
|
||||
export interface A2ARemoteAgent {
|
||||
id: number;
|
||||
project_id: number;
|
||||
@@ -12,27 +107,12 @@ export interface A2ARemoteAgent {
|
||||
failure_count: number;
|
||||
circuit_open_until?: string | null;
|
||||
card_fetched_at?: string | null;
|
||||
}
|
||||
|
||||
export interface A2ATask {
|
||||
id: string;
|
||||
project_id: number;
|
||||
source: string;
|
||||
state: string;
|
||||
remote_agent_id?: number | null;
|
||||
input_text: string;
|
||||
output_text?: string | null;
|
||||
error_message?: string | null;
|
||||
compatibility_mode: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
finished_at?: string | null;
|
||||
agent_card?: A2AAgentCard;
|
||||
}
|
||||
|
||||
export interface A2ASendMessagePayload {
|
||||
project_id: number;
|
||||
message: string;
|
||||
message: A2AMessage;
|
||||
session_id?: string;
|
||||
remote_agent_id?: number;
|
||||
route_mode?: "auto" | "local" | "a2a" | "a2a_first" | "local_first" | "mcp_first";
|
||||
@@ -55,11 +135,13 @@ export interface A2ASubscribeEvent {
|
||||
type?: string;
|
||||
event?: string;
|
||||
task_id?: string;
|
||||
context_id?: string;
|
||||
task_status?: string;
|
||||
status?: string;
|
||||
artifact?: {
|
||||
content?: string;
|
||||
};
|
||||
artifact?: A2AArtifact;
|
||||
append?: boolean;
|
||||
last_chunk?: boolean;
|
||||
message?: A2AMessage;
|
||||
output?: string;
|
||||
source?: string;
|
||||
timestamp?: string;
|
||||
@@ -122,54 +204,202 @@ export const a2aApi = {
|
||||
healthCheckRemoteAgent(agentId: number) {
|
||||
return api.post<{ healthy: boolean; failure_count: number }>(`/api/v1/a2a/remote-agents/${agentId}/health-check`, {});
|
||||
},
|
||||
listTasks(projectId: number, state?: string) {
|
||||
listTasks(projectId: number, state?: string, contextId?: string) {
|
||||
const params = new URLSearchParams({ project_id: String(projectId), limit: "100" });
|
||||
if (state && state !== "all") {
|
||||
params.set("state", state);
|
||||
}
|
||||
if (contextId) {
|
||||
params.set("context_id", contextId);
|
||||
}
|
||||
return api.get<A2ATask[]>(`/api/v1/a2a/tasks?${params.toString()}`);
|
||||
},
|
||||
getTask(taskId: string) {
|
||||
return api.get<A2ATask>(`/api/v1/a2a/tasks/${taskId}`);
|
||||
getTask(taskId: string, historyLength?: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (historyLength !== undefined) {
|
||||
params.set("historyLength", String(historyLength));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return api.get<A2ATask>(`/api/v1/a2a/tasks/${taskId}${queryString ? `?${queryString}` : ""}`);
|
||||
},
|
||||
cancelTask(taskId: string) {
|
||||
return api.post<{ task_id: string; state: string }>(`/api/v1/a2a/tasks/${taskId}/cancel`, {});
|
||||
return api.post<{ task_id: string; state: string }>(`/api/v1/a2a/tasks/${taskId}:cancel`, {});
|
||||
},
|
||||
sendMessage(payload: A2ASendMessagePayload) {
|
||||
return api.post<A2ASendMessageResponse>("/api/v1/a2a/messages/send", payload);
|
||||
return api.post<A2ASendMessageResponse>("/api/v1/a2a/message:send", payload);
|
||||
},
|
||||
async subscribeTask(taskId: string, onEvent: SubscribeHandler, signal?: AbortSignal): Promise<void> {
|
||||
const response = await fetch(`/api/v1/a2a/tasks/${taskId}/subscribe`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Subscribe failed: ${response.status}`);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const splitIndex = buffer.lastIndexOf("\n\n");
|
||||
if (splitIndex === -1) continue;
|
||||
const complete = buffer.slice(0, splitIndex);
|
||||
buffer = buffer.slice(splitIndex + 2);
|
||||
const events = parseSseEvents(complete);
|
||||
for (const event of events) {
|
||||
onEvent(event);
|
||||
streamMessage(payload: A2ASendMessagePayload) {
|
||||
return api.post<A2ASendMessageResponse>("/api/v1/a2a/message:stream", payload);
|
||||
},
|
||||
subscribeTask(taskId: string, onEvent: SubscribeHandler, signal?: AbortSignal): () => void {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
const response = await fetch(`/api/v1/a2a/tasks/${taskId}:subscribe`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
signal: signal || controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Subscribe failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const events = parseSseEvents(buffer);
|
||||
for (const event of events) {
|
||||
onEvent(event);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const splitIndex = buffer.lastIndexOf("\n\n");
|
||||
if (splitIndex === -1) continue;
|
||||
const complete = buffer.slice(0, splitIndex);
|
||||
buffer = buffer.slice(splitIndex + 2);
|
||||
const events = parseSseEvents(complete);
|
||||
for (const event of events) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const events = parseSseEvents(buffer);
|
||||
for (const event of events) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
},
|
||||
subscribeTaskSSE(taskId: string, onEvent: SubscribeHandler, signal?: AbortSignal): () => void {
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
const response = await fetch(`/api/v1/a2a/tasks/${taskId}/subscribe`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
signal: signal || controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Subscribe failed: ${response.status}`);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const splitIndex = buffer.lastIndexOf("\n\n");
|
||||
if (splitIndex === -1) continue;
|
||||
const complete = buffer.slice(0, splitIndex);
|
||||
buffer = buffer.slice(splitIndex + 2);
|
||||
const events = parseSseEvents(complete);
|
||||
for (const event of events) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const events = parseSseEvents(buffer);
|
||||
for (const event of events) {
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
},
|
||||
};
|
||||
|
||||
export function renderPart(part: A2APart): string {
|
||||
switch (part.kind) {
|
||||
case "text":
|
||||
return part.text;
|
||||
case "url":
|
||||
return `[URL: ${part.url}]`;
|
||||
case "file":
|
||||
if (part.mediaType?.startsWith("image/")) {
|
||||
return `[Image: ${part.filename || "image"}]`;
|
||||
}
|
||||
if (part.mediaType?.includes("json")) {
|
||||
try {
|
||||
const decoded = atob(part.data);
|
||||
return `[JSON File: ${part.filename || "data.json"}]\n${decoded}`;
|
||||
} catch {
|
||||
return `[Binary File: ${part.filename || "data"}]`;
|
||||
}
|
||||
}
|
||||
return `[File: ${part.filename || "file"}]`;
|
||||
default:
|
||||
return "[Unknown Part]";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderParts(parts: A2APart[]): string {
|
||||
return parts.map(renderPart).join("\n");
|
||||
}
|
||||
|
||||
export function extractTextFromParts(parts: A2APart[]): string {
|
||||
return parts
|
||||
.filter((p): p is A2APartText => p.kind === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getArtifactPreview(artifact: A2AArtifact): { type: "text" | "image" | "html" | "json" | "unknown"; content: string } {
|
||||
if (!artifact.parts || artifact.parts.length === 0) {
|
||||
return { type: "unknown", content: "" };
|
||||
}
|
||||
|
||||
const firstPart = artifact.parts[0];
|
||||
|
||||
if (firstPart.kind === "text") {
|
||||
return { type: "text", content: firstPart.text };
|
||||
}
|
||||
|
||||
if (firstPart.kind === "url") {
|
||||
const url = firstPart.url.toLowerCase();
|
||||
if (url.endsWith(".png") || url.endsWith(".jpg") || url.endsWith(".jpeg") || url.endsWith(".gif") || url.endsWith(".webp")) {
|
||||
return { type: "image", content: firstPart.url };
|
||||
}
|
||||
if (url.endsWith(".html") || url.endsWith(".htm")) {
|
||||
return { type: "html", content: firstPart.url };
|
||||
}
|
||||
return { type: "unknown", content: firstPart.url };
|
||||
}
|
||||
|
||||
if (firstPart.kind === "file") {
|
||||
const mediaType = firstPart.mediaType || "";
|
||||
if (mediaType.startsWith("image/")) {
|
||||
return { type: "image", content: `data:${mediaType};base64,${firstPart.data}` };
|
||||
}
|
||||
if (mediaType.includes("html")) {
|
||||
try {
|
||||
const decoded = atob(firstPart.data);
|
||||
return { type: "html", content: decoded };
|
||||
} catch {
|
||||
return { type: "unknown", content: "[HTML content]" };
|
||||
}
|
||||
}
|
||||
if (mediaType.includes("json")) {
|
||||
try {
|
||||
const decoded = atob(firstPart.data);
|
||||
return { type: "json", content: decoded };
|
||||
} catch {
|
||||
return { type: "unknown", content: "[JSON content]" };
|
||||
}
|
||||
}
|
||||
return { type: "unknown", content: `[File: ${firstPart.filename || "file"}]` };
|
||||
}
|
||||
|
||||
return { type: "unknown", content: "" };
|
||||
}
|
||||
|
||||
export function groupTasksByContextId(tasks: A2ATask[]): Map<string, A2ATask[]> {
|
||||
const grouped = new Map<string, A2ATask[]>();
|
||||
for (const task of tasks) {
|
||||
const contextId = task.context_id || "no-context";
|
||||
const existing = grouped.get(contextId) || [];
|
||||
existing.push(task);
|
||||
grouped.set(contextId, existing);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
+340
-10
@@ -9,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import { a2aApi, type A2ARemoteAgent, type A2ATask } from "@/api/a2a";
|
||||
import { a2aApi, type A2ARemoteAgent, type A2ATask, type A2AArtifact, renderPart, renderParts, getArtifactPreview, groupTasksByContextId } from "@/api/a2a";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { useMcpHealthStore } from "@/store/mcpHealthStore";
|
||||
import { useRef } from 'react';
|
||||
@@ -118,6 +118,11 @@ export function Skills() {
|
||||
auth_token: '',
|
||||
});
|
||||
const [isA2aRefreshingHealth, setIsA2aRefreshingHealth] = useState(false);
|
||||
const [selectedA2aAgent, setSelectedA2aAgent] = useState<A2ARemoteAgent | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<A2ATask | null>(null);
|
||||
const [taskArtifactPreview, setTaskArtifactPreview] = useState<{ type: string; content: string } | null>(null);
|
||||
const [contextIdFilter, setContextIdFilter] = useState<string>('all');
|
||||
const [groupedByContextId, setGroupedByContextId] = useState<Map<string, A2ATask[]>>(new Map());
|
||||
|
||||
const { currentProject } = useProjectStore();
|
||||
const { hasMcpError, refresh: refreshMcpHealth } = useMcpHealthStore();
|
||||
@@ -245,6 +250,7 @@ export function Skills() {
|
||||
]);
|
||||
setA2aAgents(agents || []);
|
||||
setA2aTasks(tasks || []);
|
||||
setGroupedByContextId(groupTasksByContextId(tasks || []));
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch A2A data", error);
|
||||
} finally {
|
||||
@@ -252,6 +258,25 @@ export function Skills() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewArtifact = (artifact: A2AArtifact) => {
|
||||
const preview = getArtifactPreview(artifact);
|
||||
setTaskArtifactPreview(preview);
|
||||
};
|
||||
|
||||
const handleTaskClick = (task: A2ATask) => {
|
||||
setSelectedTask(task);
|
||||
if (task.artifacts && task.artifacts.length > 0) {
|
||||
const preview = getArtifactPreview(task.artifacts[0]);
|
||||
setTaskArtifactPreview(preview);
|
||||
} else {
|
||||
setTaskArtifactPreview(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentCardClick = (agent: A2ARemoteAgent) => {
|
||||
setSelectedA2aAgent(agent);
|
||||
};
|
||||
|
||||
const handleRefreshA2aHealth = async () => {
|
||||
if (!currentProject || a2aAgents.length === 0) return;
|
||||
setIsA2aRefreshingHealth(true);
|
||||
@@ -602,6 +627,20 @@ export function Skills() {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Select value={contextIdFilter} onValueChange={(val) => { if (val) setContextIdFilter(val); }}>
|
||||
<SelectTrigger className="w-[180px] h-9">
|
||||
<SelectValue placeholder={t('filterByContext')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('allContexts')}</SelectItem>
|
||||
{Array.from(groupedByContextId.keys()).filter(k => k !== 'no-context').map(contextId => (
|
||||
<SelectItem key={contextId} value={contextId}>{contextId.slice(0, 16)}...</SelectItem>
|
||||
))}
|
||||
{groupedByContextId.has('no-context') && (
|
||||
<SelectItem value="no-context">{t('noContext')}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
|
||||
onClick={handleOpenCreateA2a}
|
||||
@@ -856,7 +895,7 @@ export function Skills() {
|
||||
</TableRow>
|
||||
) : (
|
||||
a2aAgents.map((agent) => (
|
||||
<TableRow key={agent.id} className="group hover:bg-muted/50/50 transition-colors border-border">
|
||||
<TableRow key={agent.id} className="group hover:bg-muted/50/50 transition-colors border-border cursor-pointer" onClick={() => handleAgentCardClick(agent)}>
|
||||
<TableCell className="py-4 px-4 text-sm font-medium">{agent.name}</TableCell>
|
||||
<TableCell className="py-4 px-4 text-sm text-muted-foreground truncate" title={agent.base_url}>{agent.base_url}</TableCell>
|
||||
<TableCell className="py-4 px-4 text-sm text-muted-foreground">{agent.protocol_version || '-'}</TableCell>
|
||||
@@ -868,7 +907,7 @@ export function Skills() {
|
||||
<span className="opacity-70">#{agent.failure_count}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-4 px-4 text-right">
|
||||
<TableCell className="py-4 px-4 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-indigo-600 hover:bg-indigo-50 rounded-md transition-all shrink-0" onClick={() => void handleRefreshA2aCard(agent.id)}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
@@ -895,10 +934,11 @@ export function Skills() {
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader className="bg-muted/50/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[18%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('taskId')}</TableHead>
|
||||
<TableHead className="w-[12%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('taskSource')}</TableHead>
|
||||
<TableHead className="w-[12%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('status')}</TableHead>
|
||||
<TableHead className="w-[38%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('content')}</TableHead>
|
||||
<TableHead className="w-[16%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('taskId')}</TableHead>
|
||||
<TableHead className="w-[12%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('contextId')}</TableHead>
|
||||
<TableHead className="w-[10%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('taskSource')}</TableHead>
|
||||
<TableHead className="w-[10%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('status')}</TableHead>
|
||||
<TableHead className="w-[32%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('content')}</TableHead>
|
||||
<TableHead className="w-[20%] font-semibold text-foreground/80 py-3 px-4 text-sm">{t('time')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -915,14 +955,20 @@ export function Skills() {
|
||||
</TableRow>
|
||||
) : (
|
||||
a2aTasks.map((task) => (
|
||||
<TableRow key={task.id} className="group hover:bg-muted/50/50 transition-colors border-border">
|
||||
<TableRow key={task.id} className="group hover:bg-muted/50/50 transition-colors border-border cursor-pointer" onClick={() => handleTaskClick(task)}>
|
||||
<TableCell className="py-4 px-4 text-xs font-mono truncate" title={task.id}>{task.id}</TableCell>
|
||||
<TableCell className="py-4 px-4 text-xs font-mono truncate text-muted-foreground" title={task.context_id || ''}>{task.context_id ? task.context_id.slice(0, 12) + '...' : '-'}</TableCell>
|
||||
<TableCell className="py-4 px-4 text-sm text-muted-foreground">{task.source}</TableCell>
|
||||
<TableCell className="py-4 px-4 text-sm">{task.state}</TableCell>
|
||||
<TableCell className="py-4 px-4 text-xs text-muted-foreground">
|
||||
<div className="line-clamp-2" title={task.error_message || task.output_text || task.input_text}>
|
||||
{task.error_message || task.output_text || task.input_text}
|
||||
<div className="line-clamp-2" title={task.error_message || task.output_text || task.input_text || (task.input_parts ? renderParts(task.input_parts) : '')}>
|
||||
{task.error_message || task.output_text || task.input_text || (task.input_parts ? renderParts(task.input_parts) : '')}
|
||||
</div>
|
||||
{task.artifacts && task.artifacts.length > 0 && (
|
||||
<div className="mt-1 text-[10px] text-indigo-600">
|
||||
{task.artifacts.length} artifact(s)
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-4 px-4 text-xs text-muted-foreground">{task.updated_at}</TableCell>
|
||||
</TableRow>
|
||||
@@ -1203,6 +1249,290 @@ export function Skills() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!selectedA2aAgent} onOpenChange={(open) => { if (!open) setSelectedA2aAgent(null); }}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col rounded-2xl p-0 overflow-hidden">
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogTitle className="text-xl font-bold text-foreground">{selectedA2aAgent?.name} - Agent Card</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-2">
|
||||
{selectedA2aAgent?.agent_card ? (
|
||||
<div className="grid gap-4">
|
||||
{selectedA2aAgent.agent_card.description && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('description')}</Label>
|
||||
<p className="text-sm text-foreground">{selectedA2aAgent.agent_card.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.url && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">URL</Label>
|
||||
<a href={selectedA2aAgent.agent_card.url} target="_blank" rel="noopener noreferrer" className="text-sm text-indigo-600 hover:underline">{selectedA2aAgent.agent_card.url}</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.provider && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('provider')}</Label>
|
||||
<div className="text-sm text-foreground">
|
||||
{selectedA2aAgent.agent_card.provider.organization && <span>{selectedA2aAgent.agent_card.provider.organization}</span>}
|
||||
{selectedA2aAgent.agent_card.provider.url && <span> - <a href={selectedA2aAgent.agent_card.provider.url} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline">{selectedA2aAgent.agent_card.provider.url}</a></span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.skills && selectedA2aAgent.agent_card.skills.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('skills')}</Label>
|
||||
<div className="space-y-2">
|
||||
{selectedA2aAgent.agent_card.skills.map((skill, idx) => (
|
||||
<div key={idx} className="p-3 bg-muted/50 rounded-lg">
|
||||
<div className="font-medium text-sm">{skill.name}</div>
|
||||
{skill.description && <p className="text-xs text-muted-foreground mt-1">{skill.description}</p>}
|
||||
{skill.tags && skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{skill.tags.map((tag, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-[10px] rounded-full">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{skill.inputModes && skill.inputModes.length > 0 && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">Input: {skill.inputModes.join(', ')}</div>
|
||||
)}
|
||||
{skill.outputModes && skill.outputModes.length > 0 && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">Output: {skill.outputModes.join(', ')}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.supportedInterfaces && selectedA2aAgent.agent_card.supportedInterfaces.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('supportedInterfaces')}</Label>
|
||||
<div className="space-y-1">
|
||||
{selectedA2aAgent.agent_card.supportedInterfaces.map((iface, idx) => (
|
||||
<div key={idx} className="p-2 bg-muted/50 rounded text-xs">
|
||||
<span className="font-medium">{iface.type}</span>
|
||||
{iface.url && <span className="text-muted-foreground ml-2">{iface.url}</span>}
|
||||
{iface.protocolVersion && <span className="text-muted-foreground ml-2">v{iface.protocolVersion}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.defaultInputModes && selectedA2aAgent.agent_card.defaultInputModes.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('defaultInputModes')}</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedA2aAgent.agent_card.defaultInputModes.map((mode, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 bg-muted rounded text-xs">{mode}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.defaultOutputModes && selectedA2aAgent.agent_card.defaultOutputModes.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('defaultOutputModes')}</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedA2aAgent.agent_card.defaultOutputModes.map((mode, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 bg-muted rounded text-xs">{mode}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.iconUrl && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('iconUrl')}</Label>
|
||||
<img src={selectedA2aAgent.agent_card.iconUrl} alt="Agent Icon" className="h-16 w-16 rounded-lg object-contain" />
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.documentationUrl && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('documentationUrl')}</Label>
|
||||
<a href={selectedA2aAgent.agent_card.documentationUrl} target="_blank" rel="noopener noreferrer" className="text-sm text-indigo-600 hover:underline">{selectedA2aAgent.agent_card.documentationUrl}</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedA2aAgent.agent_card.security && selectedA2aAgent.agent_card.security.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('security')}</Label>
|
||||
<div className="text-xs text-muted-foreground font-mono bg-muted/50 p-2 rounded">
|
||||
{JSON.stringify(selectedA2aAgent.agent_card.security, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>{t('noAgentCardAvailable')}</p>
|
||||
<p className="text-xs mt-2">{t('tryRefreshingCard')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="p-6 pt-2">
|
||||
<Button variant="outline" onClick={() => setSelectedA2aAgent(null)}>{t('close')}</Button>
|
||||
{selectedA2aAgent && (
|
||||
<Button onClick={() => void handleRefreshA2aCard(selectedA2aAgent.id)} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('refreshCard')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!selectedTask} onOpenChange={(open) => { if (!open) { setSelectedTask(null); setTaskArtifactPreview(null); } }}>
|
||||
<DialogContent className="sm:max-w-[900px] max-h-[90vh] flex flex-col rounded-2xl p-0 overflow-hidden">
|
||||
<DialogHeader className="p-6 pb-2">
|
||||
<DialogTitle className="text-xl font-bold text-foreground">{t('taskDetails')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-2">
|
||||
{selectedTask && (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">Task ID</Label>
|
||||
<p className="text-sm font-mono break-all">{selectedTask.id}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">Context ID</Label>
|
||||
<p className="text-sm font-mono break-all">{selectedTask.context_id || '-'}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">State</Label>
|
||||
<p className="text-sm">{selectedTask.state}</p>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">Source</Label>
|
||||
<p className="text-sm">{selectedTask.source}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTask.input_parts && selectedTask.input_parts.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('inputParts')}</Label>
|
||||
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
|
||||
{selectedTask.input_parts.map((part, idx) => (
|
||||
<div key={idx} className="text-sm">
|
||||
{part.kind === 'text' && <p className="whitespace-pre-wrap">{part.text}</p>}
|
||||
{part.kind === 'url' && <a href={part.url} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline">{part.url}</a>}
|
||||
{part.kind === 'file' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">[{part.filename || 'file'}]</span>
|
||||
{part.mediaType?.startsWith('image/') && <span className="text-xs text-muted-foreground">({part.mediaType})</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.output_parts && selectedTask.output_parts.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('outputParts')}</Label>
|
||||
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
|
||||
{selectedTask.output_parts.map((part, idx) => (
|
||||
<div key={idx} className="text-sm">
|
||||
{part.kind === 'text' && <p className="whitespace-pre-wrap">{part.text}</p>}
|
||||
{part.kind === 'url' && <a href={part.url} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline">{part.url}</a>}
|
||||
{part.kind === 'file' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">[{part.filename || 'file'}]</span>
|
||||
{part.mediaType?.startsWith('image/') && <span className="text-xs text-muted-foreground">({part.mediaType})</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.artifacts && selectedTask.artifacts.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('artifacts')} ({selectedTask.artifacts.length})</Label>
|
||||
<div className="space-y-2">
|
||||
{selectedTask.artifacts.map((artifact, idx) => (
|
||||
<div key={idx} className="border border-border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium text-sm">
|
||||
{artifact.name || `Artifact ${idx + 1}`}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1" onClick={() => handlePreviewArtifact(artifact)}>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t('preview')}
|
||||
</Button>
|
||||
</div>
|
||||
{artifact.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">{artifact.description}</p>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{artifact.parts.length} part(s)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskArtifactPreview && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('artifactPreview')}</Label>
|
||||
<div className="border border-border rounded-lg p-3 bg-muted/50">
|
||||
{taskArtifactPreview.type === 'text' && (
|
||||
<pre className="text-xs whitespace-pre-wrap break-all max-h-[300px] overflow-auto">{taskArtifactPreview.content}</pre>
|
||||
)}
|
||||
{taskArtifactPreview.type === 'image' && (
|
||||
<img src={taskArtifactPreview.content} alt="Artifact Preview" className="max-w-full max-h-[300px] rounded-lg object-contain" />
|
||||
)}
|
||||
{taskArtifactPreview.type === 'html' && (
|
||||
<div className="text-xs text-muted-foreground italic">[HTML Preview - rendered separately]</div>
|
||||
)}
|
||||
{taskArtifactPreview.type === 'json' && (
|
||||
<pre className="text-xs whitespace-pre-wrap break-all max-h-[300px] overflow-auto">{taskArtifactPreview.content}</pre>
|
||||
)}
|
||||
{taskArtifactPreview.type === 'unknown' && (
|
||||
<p className="text-xs text-muted-foreground">{taskArtifactPreview.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.error_message && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('errorMessage')}</Label>
|
||||
<div className="bg-rose-50 border border-rose-100 rounded-lg p-3 text-sm text-rose-700">
|
||||
{selectedTask.error_message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask.history && selectedTask.history.length > 0 && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground font-medium text-sm">{t('messageHistory')} ({selectedTask.history.length})</Label>
|
||||
<div className="space-y-2 max-h-[300px] overflow-auto">
|
||||
{selectedTask.history.map((msg, idx) => (
|
||||
<div key={idx} className={`p-3 rounded-lg ${msg.role === 'user' ? 'bg-blue-50 border border-blue-100' : 'bg-green-50 border border-green-100'}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-medium">{msg.role}</span>
|
||||
{msg.messageId && <span className="text-[10px] text-muted-foreground font-mono">{msg.messageId.slice(0, 8)}...</span>}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{msg.parts.map((part, pIdx) => (
|
||||
<div key={pIdx}>{renderPart(part)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="p-6 pt-2">
|
||||
<Button variant="outline" onClick={() => { setSelectedTask(null); setTaskArtifactPreview(null); }}>{t('close')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user