Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 81 additions & 11 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CircleCheck,
CircleX,
Copy,
CornerDownRight,
ListTodo,
Loader2,
} from "lucide-react";
Expand Down Expand Up @@ -142,7 +143,7 @@ function emptyInvocation(): FrontendInvocation {
}

function findAgentNode(node: AgentNode, name: string): AgentNode | undefined {
if (node.name === name) return node;
if (node.name === name || node.id === name) return node;
for (const child of node.children) {
const found = findAgentNode(child, name);
if (found) return found;
Expand Down Expand Up @@ -253,6 +254,7 @@ function turnHasVisibleContent(turn: Turn): boolean {
if (b.kind === "text") return b.text.trim().length > 0;
if (b.kind === "attachment") return b.files.length > 0;
if (b.kind === "tool") return !(b.name === A2UI_TOOL_NAME && b.done);
if (b.kind === "agent-transfer") return false;
if (b.kind === "a2ui") return buildSurfaces(b.messages).some((s) => s.components[s.rootId]);
if (b.kind === "auth") return true; // the OAuth card counts as content
return false; // thinking is not an answer
Expand Down Expand Up @@ -735,6 +737,11 @@ export default function App() {
const seenAgents = seenAgentsBySession[sessionId] ?? EMPTY_STRING_SET;
const execPath = execPathBySession[sessionId] ?? EMPTY_STRING_ARR;
const rootCapabilityNode = agentInfo?.graph;
const rootAgentNames = [
agentInfo?.name,
rootCapabilityNode?.name,
rootCapabilityNode?.id,
].filter((name): name is string => Boolean(name));
const skillCapabilityNode = invocation.targetAgent && rootCapabilityNode
? findAgentNode(rootCapabilityNode, invocation.targetAgent.name)
: rootCapabilityNode;
Expand Down Expand Up @@ -1810,6 +1817,7 @@ export default function App() {

try {
let acc = emptyAcc();
let currentStreamAuthor = "";
let tokens = 0;
let ts = Date.now() / 1000;
let eventId = "";
Expand All @@ -1832,6 +1840,13 @@ export default function App() {
}
// Live topology: author + transfer/end signals, keyed by session.
applyStreamSignals(sid, event);
const eventAuthor = event.author && event.author !== "user"
? event.author
: "";
if (eventAuthor && eventAuthor !== currentStreamAuthor) {
currentStreamAuthor = eventAuthor;
acc = emptyAcc();
}
acc = applyEvent(acc, event);
const usage = event.usageMetadata ?? event.usage_metadata;
if (usage?.totalTokenCount) tokens = usage.totalTokenCount;
Expand All @@ -1841,6 +1856,7 @@ export default function App() {
if (nextInvocationId) invocationId = nextInvocationId;
const blocks = acc.blocks;
const meta = {
author: currentStreamAuthor || undefined,
tokens: tokens || undefined,
ts,
eventId: eventId || undefined,
Expand All @@ -1849,7 +1865,14 @@ export default function App() {
setTurnsFor(sid, (t) => {
const next = t.slice();
const last = next[next.length - 1];
if (last?.role === "assistant") next[next.length - 1] = { ...last, blocks, meta };
if (
last?.role === "assistant" &&
(!last.meta?.author || last.meta.author === currentStreamAuthor)
) {
next[next.length - 1] = { ...last, blocks, meta };
} else {
next.push({ role: "assistant", blocks, meta });
}
return next;
});
}
Expand Down Expand Up @@ -1917,6 +1940,8 @@ export default function App() {
startStreamPresentation(sid);
try {
let acc = emptyAcc();
let currentStreamAuthor = lastTurn?.meta?.author ?? "";
let currentBase = base;
let tokens = 0;
let ts = Date.now() / 1000;
let eventId = lastTurn?.meta?.eventId ?? "";
Expand All @@ -1934,28 +1959,43 @@ export default function App() {
})) {
if (ctrl.signal.aborted) break;
applyStreamSignals(sid, event);
const eventAuthor = event.author && event.author !== "user"
? event.author
: "";
if (eventAuthor && eventAuthor !== currentStreamAuthor) {
currentStreamAuthor = eventAuthor;
currentBase = [];
acc = emptyAcc();
}
acc = applyEvent(acc, event);
const usage = event.usageMetadata ?? event.usage_metadata;
if (usage?.totalTokenCount) tokens = usage.totalTokenCount;
if (event.timestamp) ts = event.timestamp;
if (event.id) eventId = event.id;
const nextInvocationId = event.invocationId ?? event.invocation_id;
if (nextInvocationId) invocationId = nextInvocationId;
const blocks = [...base, ...acc.blocks];
const blocks = [...currentBase, ...acc.blocks];
setTurnsFor(sid, (t) => {
const next = t.slice();
const last = next[next.length - 1];
if (last?.role === "assistant") {
const meta = {
author: currentStreamAuthor || last?.meta?.author,
tokens: tokens || last?.meta?.tokens,
ts,
eventId: eventId || last?.meta?.eventId,
invocationId: invocationId || last?.meta?.invocationId,
};
if (
last?.role === "assistant" &&
(!last.meta?.author || last.meta.author === currentStreamAuthor)
) {
next[next.length - 1] = {
...last,
blocks,
meta: {
tokens: tokens || last.meta?.tokens,
ts,
eventId: eventId || last.meta?.eventId,
invocationId: invocationId || last.meta?.invocationId,
},
meta,
};
} else {
next.push({ role: "assistant", blocks, meta });
}
return next;
});
Expand Down Expand Up @@ -2622,6 +2662,22 @@ export default function App() {
</motion.div>
);
}
const agentAuthor = turn.meta?.author ?? "";
const agentNode = agentAuthor && rootCapabilityNode
? findAgentNode(rootCapabilityNode, agentAuthor)
: undefined;
const isSubAgent = Boolean(
agentAuthor &&
rootAgentNames.length > 0 &&
!rootAgentNames.includes(agentAuthor),
);
const agentDisplayName = agentNode?.name || agentAuthor;
const agentDescription = agentNode?.description ||
(isSubAgent ? "正在执行主 Agent 移交的任务。" : "");
if (
turn.blocks.length > 0 &&
turn.blocks.every((block) => block.kind === "agent-transfer")
) return null;
const pending = turn.blocks.length === 0;
const feedbackRating = turn.meta?.feedback?.rating ?? null;
const feedbackEventId = turn.meta?.eventId ?? "";
Expand All @@ -2632,11 +2688,25 @@ export default function App() {
return (
<motion.div
key={i}
className="turn turn--assistant"
className={`turn turn--assistant${isSubAgent ? " turn--subagent" : ""}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
>
{isSubAgent && (
<>
<div className="subagent-run-label">
<span className="subagent-run-handoff">
<CornerDownRight />
<span>智能体移交</span>
</span>
<span className="subagent-run-title">{agentDisplayName}</span>
</div>
<p className="subagent-run-description" title={agentDescription}>
{agentDescription}
</p>
</>
)}
{pending ? (
isLast && activeConversationBusy ? <ThinkingPlaceholder /> : null
) : (
Expand Down
38 changes: 35 additions & 3 deletions frontend/src/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const A2UI_TOOL = "send_a2ui_json_to_client";
const VALIDATED_JSON_KEY = "validated_a2ui_json";
/** ADK's special function call that requests OAuth/credentials for a tool. */
const REQUEST_EUC = "adk_request_credential";
const TRANSFER_AGENT_TOOL = "transfer_to_agent";

/** Pull the OAuth2 authorize URL out of an ADK AuthConfig (camelCase over
* /run_sse, snake_case in stored history — handle both). */
Expand All @@ -49,6 +50,7 @@ export type Block =
| { kind: "thinking"; text: string; done: boolean }
| { kind: "text"; text: string }
| { kind: "tool"; name: string; args?: unknown; response?: unknown; done: boolean }
| { kind: "agent-transfer"; agentName: string; done: boolean }
| { kind: "a2ui"; messages: A2uiMessage[] }
| { kind: "attachment"; files: AttachmentView[] }
| { kind: "invocation"; value: FrontendInvocation }
Expand All @@ -70,6 +72,7 @@ export interface Acc {
}

export interface TurnMeta {
author?: string;
tokens?: number;
ts?: number; // epoch seconds
eventId?: string;
Expand All @@ -90,6 +93,13 @@ export function emptyAcc(): Acc {
const fnCall = (p: AdkPart) => p.functionCall ?? p.function_call;
const fnResp = (p: AdkPart) => p.functionResponse ?? p.function_response;

function transferAgentName(args: unknown): string {
if (!args || typeof args !== "object") return "";
const record = args as Record<string, unknown>;
const name = record.agentName ?? record.agent_name;
return typeof name === "string" ? name : "";
}

/** ADK/genai serialises inline_data bytes as URL-safe base64 (-_), but a
* `data:` URI requires standard base64 (+/). Convert so reloaded images
* render instead of failing to a broken <img>. */
Expand Down Expand Up @@ -249,7 +259,14 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
appendAttachments(blocks, files);
} else if (fc) {
closeThinking(blocks);
if (fc.name === REQUEST_EUC) {
if (fc.name === TRANSFER_AGENT_TOOL) {
const agentName =
transferAgentName(fc.args) ||
ev.actions?.transferToAgent ||
ev.actions?.transfer_to_agent ||
"未知 Agent";
blocks.push({ kind: "agent-transfer", agentName, done: false });
} else if (fc.name === REQUEST_EUC) {
// MCP/tool OAuth: render a dedicated auth card instead of a tool row.
const args = (fc.args ?? {}) as Record<string, any>;
const authConfig = args.authConfig ?? args.auth_config ?? args;
Expand All @@ -270,6 +287,15 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc {
}
} else if (fr) {
closeThinking(blocks);
if (fr.name === TRANSFER_AGENT_TOOL) {
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b.kind === "agent-transfer" && !b.done) {
b.done = true;
break;
}
}
}
// A credential response resolves the matching auth card.
if (fr.name === REQUEST_EUC) {
for (let i = blocks.length - 1; i >= 0; i--) {
Expand Down Expand Up @@ -347,16 +373,22 @@ export function eventsToTurns(
turns.push({ role: "user", blocks, meta: { ts: ev.timestamp } });
acc = emptyAcc();
} else {
const author = ev.author ?? "";
let last = turns[turns.length - 1];
if (!last || last.role !== "assistant") {
last = { role: "assistant", blocks: [], meta: {} };
if (
!last ||
last.role !== "assistant" ||
(author && last.meta?.author !== author)
) {
last = { role: "assistant", blocks: [], meta: { author: author || undefined } };
turns.push(last);
acc = emptyAcc();
}
acc = applyEvent(acc, ev);
last.blocks = acc.blocks;
const usage = ev.usageMetadata ?? ev.usage_metadata;
const meta = (last.meta ??= {});
if (author) meta.author = author;
if (usage?.totalTokenCount) meta.tokens = usage.totalTokenCount;
if (ev.timestamp) meta.ts = ev.timestamp;
if (ev.id) meta.eventId = ev.id;
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,104 @@ body {
min-height: max(0px, calc(100% - 180px));
}

.turn--subagent {
isolation: isolate;
position: relative;
width: 100%;
max-width: 768px;
margin-top: 34px;
margin-bottom: 48px;
padding: 30px 16px 14px;
gap: 10px;
border: 1px solid hsl(215 20% 88% / 0.82);
border-radius: 14px;
background: hsl(210 22% 98% / 0.68);
box-shadow:
inset 0 1px 0 hsl(0 0% 100% / 0.8),
0 14px 36px hsl(215 28% 28% / 0.06);
backdrop-filter: blur(18px) saturate(115%);
-webkit-backdrop-filter: blur(18px) saturate(115%);
}
.turn--subagent::before {
position: absolute;
z-index: -1;
inset: 0;
overflow: hidden;
border-radius: inherit;
background:
radial-gradient(circle at 12% 8%, hsl(210 38% 92% / 0.55), transparent 38%),
radial-gradient(circle at 88% 78%, hsl(220 22% 91% / 0.42), transparent 42%),
linear-gradient(120deg, hsl(0 0% 100% / 0.56), hsl(215 24% 96% / 0.26));
content: "";
pointer-events: none;
}
.transcript.is-streaming > .turn--subagent:last-child { min-height: 0; }
.subagent-run-label {
position: absolute;
top: 0;
left: 14px;
display: inline-flex;
min-height: 36px;
max-width: calc(100% - 28px);
padding: 4px 9px 4px 4px;
align-items: center;
gap: 8px;
border: 1px solid hsl(215 18% 86%);
border-radius: 10px;
background: hsl(var(--background));
box-shadow: 0 4px 12px hsl(215 25% 30% / 0.07);
transform: translateY(-50%);
}
.subagent-run-handoff {
display: inline-flex;
height: 26px;
padding: 0 8px 0 6px;
flex: 0 0 auto;
align-items: center;
gap: 5px;
border-radius: 7px;
background: hsl(210 22% 95%);
color: hsl(215 12% 43%);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
}
.subagent-run-handoff svg { width: 15px; height: 15px; flex: 0 0 15px; }
.subagent-run-title {
min-width: 0;
overflow: hidden;
color: hsl(var(--foreground));
font-size: 14.5px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.subagent-run-description {
display: -webkit-box;
margin: 0;
padding: 0 2px 4px;
overflow: hidden;
color: hsl(215 10% 43%);
font-size: 13.5px;
line-height: 1.6;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.turn--subagent .turn-meta {
position: absolute;
bottom: -38px;
left: 0;
margin-top: 0;
}

@media (max-width: 700px) {
.turn--subagent {
width: 100%;
padding: 30px 10px 12px;
}
.subagent-run-label { left: 10px; max-width: calc(100% - 20px); }
}

.bubble { line-height: 1.65; font-size: 16px; }
.turn--user .bubble {
max-width: 85%;
Expand Down
Loading
Loading