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
72 changes: 72 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,21 @@ function loadView(): CreateView {
}
import { TraceDrawer } from "./ui/TraceDrawer";
import { LoginPage } from "./ui/LoginPage";
import { AuthExpiredDialog } from "./ui/AuthExpiredDialog";
import { Markdown } from "./ui/Markdown";
import {
clearLocalUser,
logout,
openLoginWindow,
resolveIdentity,
setLocalUser,
type AuthStatus,
} from "./adk/identity";
import {
AUTHENTICATION_REQUIRED_EVENT,
authenticationRestored,
isAuthenticationPending,
} from "./adk/authSession";
import type { A2uiAction, A2uiComponent } from "./a2ui/types";
import { buildSurfaces } from "./a2ui/Surface";

Expand Down Expand Up @@ -671,6 +678,10 @@ export default function App() {
const closeAgentInfo = useCallback(() => setAgentInfoOpen(false), []);
const [greeting, setGreeting] = useState(pickGreeting);
const [authStatus, setAuthStatus] = useState<AuthStatus | null>(null);
const [authExpired, setAuthExpired] = useState(false);
const [authRecoveryChecking, setAuthRecoveryChecking] = useState(false);
const [authRecoveryError, setAuthRecoveryError] = useState("");
const authRecoveryActiveRef = useRef(false);
const [authError, setAuthError] = useState<string | null>(null);
const [userId, setUserId] = useState("");
const [userInfo, setUserInfo] = useState<Record<string, unknown> | undefined>();
Expand Down Expand Up @@ -1008,6 +1019,60 @@ export default function App() {
resolveAuth();
}, [resolveAuth]);

useEffect(() => {
const showExpiredDialog = () => {
setAuthRecoveryError("");
setAuthExpired(true);
};
window.addEventListener(AUTHENTICATION_REQUIRED_EVENT, showExpiredDialog);
if (isAuthenticationPending()) showExpiredDialog();
return () =>
window.removeEventListener(
AUTHENTICATION_REQUIRED_EVENT,
showExpiredDialog,
);
}, []);

const recoverAuthentication = useCallback(async () => {
if (authRecoveryActiveRef.current) return;
authRecoveryActiveRef.current = true;
const loginWindow = openLoginWindow();
if (!loginWindow) {
authRecoveryActiveRef.current = false;
setAuthRecoveryError("登录窗口被浏览器拦截,请允许弹出窗口后重试。");
return;
}
setAuthRecoveryChecking(true);
setAuthRecoveryError("");
try {
while (true) {
await new Promise((resolve) => window.setTimeout(resolve, 1000));
try {
const identity = await resolveIdentity();
if (identity.status === "authenticated") {
setUserId(identity.userId);
setUserInfo(identity.info);
setLocalMode(!!identity.local);
setAuthStatus(identity.status);
setAuthExpired(false);
authenticationRestored();
loginWindow.close();
return;
}
} catch {
// The gateway may return its login page until the popup completes.
}
if (loginWindow.closed) {
setAuthRecoveryError("登录窗口已关闭,请重新登录以继续当前操作。");
return;
}
}
} finally {
authRecoveryActiveRef.current = false;
setAuthRecoveryChecking(false);
}
}, []);

useEffect(() => {
if (localMode && userId) setLocalUser(userId);
}, [localMode, userId]);
Expand Down Expand Up @@ -2724,6 +2789,13 @@ export default function App() {
onConfirm={() => void launchSandboxSession()}
/>

<AuthExpiredDialog
open={authExpired}
checking={authRecoveryChecking}
error={authRecoveryError}
onLogin={() => void recoverAuthentication()}
/>

{confirmLeave && (
<div className="confirm-scrim" onClick={() => setConfirmLeave(false)}>
<div className="confirm-box" onClick={(e) => e.stopPropagation()}>
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/adk/authSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export const AUTHENTICATION_REQUIRED_EVENT = "veadk:authentication-required";

let pendingAuthentication: Promise<void> | null = null;
let resolveAuthentication: (() => void) | null = null;

export function isAuthenticationRedirect(response: Response): boolean {
if (!response.redirected || !response.url) return false;
try {
const url = new URL(response.url);
return (
url.pathname.includes("/authorize") ||
url.pathname.includes("/oauth2/login") ||
url.hostname.includes(".userpool.auth.")
);
} catch {
return false;
}
}

export function waitForAuthentication(signal?: AbortSignal | null): Promise<void> {
if (!pendingAuthentication) {
pendingAuthentication = new Promise<void>((resolve) => {
resolveAuthentication = resolve;
});
window.dispatchEvent(new Event(AUTHENTICATION_REQUIRED_EVENT));
}
const authentication = pendingAuthentication;
if (!signal) return authentication;
if (signal.aborted) {
return Promise.reject(signal.reason ?? new Error("Request aborted"));
}
return new Promise<void>((resolve, reject) => {
const onAbort = () => reject(signal.reason ?? new Error("Request aborted"));
signal.addEventListener("abort", onAbort, { once: true });
authentication.then(
() => {
signal.removeEventListener("abort", onAbort);
resolve();
},
(error) => {
signal.removeEventListener("abort", onAbort);
reject(error);
},
);
});
}

export function isAuthenticationPending(): boolean {
return pendingAuthentication !== null;
}

export function authenticationRestored(): void {
resolveAuthentication?.();
resolveAuthentication = null;
pendingAuthentication = null;
}
69 changes: 52 additions & 17 deletions frontend/src/adk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
// the Vite dev proxy in development.

import { withAuth } from "./auth";
import { isOAuthLoginRequired, withLocalUser } from "./identity";
import {
isAuthenticationRedirect,
waitForAuthentication,
} from "./authSession";
import { parseJsonResponse } from "./jsonResponse";
import { formatRunSseError } from "./runSseError";
import { parseSSE } from "./sse";
import {
Expand All @@ -12,7 +18,6 @@ import {
} from "./timeout";
import type { AgentProject } from "../create/project";
import type { AgentDraft } from "../create/types";
import { withLocalUser } from "./identity";

/** An ADK event as serialised over `/run_sse` (camelCase, by_alias=True). */
export interface AdkUsage {
Expand Down Expand Up @@ -226,29 +231,59 @@ function resolve(appName: string): { app: string; ep: AdkEndpoint } {
* the apikey; apikey never reaches the browser).
* 2. `base` + `apiKey` → backend `/agentkit-proxy` (legacy, key in header).
* 3. neither → the local same-origin server. */
function apiFetch(
async function apiFetch(
path: string,
init: RequestInit = {},
ep: AdkEndpoint = {},
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<Response> {
const opts = {
const baseOpts = {
...init,
headers: withLocalUser(init.headers),
signal: requestSignal(init.signal, timeoutMs),
};
if (ep.runtimeId) {
const rq = ep.region ? `${path.includes("?") ? "&" : "?"}region=${encodeURIComponent(ep.region)}` : "";
return fetch(withAuth(`${API_BASE}/web/runtime-proxy/${ep.runtimeId}${path}${rq}`), opts);
}
if (ep.base) {
// Use backend proxy to avoid CORS issues with remote AgentKit
const headers = new Headers(opts.headers);
headers.set("X-AgentKit-Base", ep.base);
if (ep.apiKey) headers.set("X-AgentKit-Key", ep.apiKey);
return fetch(withAuth(`${API_BASE}/agentkit-proxy${path}`), { ...opts, headers });
const send = () => {
const opts = {
...baseOpts,
signal: requestSignal(init.signal, timeoutMs),
};
if (ep.runtimeId) {
const rq = ep.region
? `${path.includes("?") ? "&" : "?"}region=${encodeURIComponent(ep.region)}`
: "";
return fetch(
withAuth(`${API_BASE}/web/runtime-proxy/${ep.runtimeId}${path}${rq}`),
opts,
);
}
if (ep.base) {
// Use backend proxy to avoid CORS issues with remote AgentKit
const headers = new Headers(opts.headers);
headers.set("X-AgentKit-Base", ep.base);
if (ep.apiKey) headers.set("X-AgentKit-Key", ep.apiKey);
return fetch(withAuth(`${API_BASE}/agentkit-proxy${path}`), {
...opts,
headers,
});
}
return fetch(withAuth(`${API_BASE}${path}`), opts);
};

const requiresLogin = async (response: Response) => {
if (isAuthenticationRedirect(response)) return true;
if (response.status !== 401) return false;
try {
return await isOAuthLoginRequired();
} catch {
return false;
}
};

let response = await send();
while (await requiresLogin(response)) {
await waitForAuthentication(init.signal);
response = await send();
}
return fetch(withAuth(`${API_BASE}${path}`), opts);
return response;
}

function formatErrorDetail(detail: unknown): string {
Expand Down Expand Up @@ -1566,7 +1601,7 @@ export async function createGeneratedAgentTestRun(
if (!res.ok) {
throw new Error(await httpErrorMessage(res, "创建调试运行失败"));
}
return res.json();
return parseJsonResponse<GeneratedAgentTestRun>(res, "创建调试运行失败");
}

export async function createGeneratedAgentTestSession(
Expand All @@ -1581,7 +1616,7 @@ export async function createGeneratedAgentTestSession(
if (!res.ok) {
throw new Error(await httpErrorMessage(res, "创建调试会话失败"));
}
const session = await res.json();
const session = await parseJsonResponse<{ id: string }>(res, "创建调试会话失败");
return session.id;
}

Expand Down
31 changes: 31 additions & 0 deletions frontend/src/adk/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,37 @@ export function login(): void {
loginTo("/oauth2/login");
}

/** Open the login flow without unloading the current editing context. */
export function openLoginWindow(): Window | null {
const here = window.location.pathname + window.location.search + window.location.hash;
const loginWindow = window.open(
"about:blank",
"_blank",
"popup,width=520,height=720",
);
if (!loginWindow) return null;
try {
loginWindow.opener = null;
loginWindow.location.replace(
`/oauth2/login?redirect=${encodeURIComponent(here)}`,
);
} catch {
loginWindow.close();
return null;
}
return loginWindow;
}

/** Confirm that a 401 came from an expired built-in OAuth session rather than
* an upstream runtime or another API-specific authorization check. */
export async function isOAuthLoginRequired(): Promise<boolean> {
const [identity, providers] = await Promise.all([
resolveIdentity(),
fetchProviders(),
]);
return identity.status === "unauthenticated" && providers.length > 0;
}

export function logout(): void {
window.location.assign("/oauth2/logout");
}
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/adk/jsonResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Parse a successful JSON API response without hiding HTML gateway errors. */
export async function parseJsonResponse<T>(
response: Response,
fallback: string,
): Promise<T> {
const text = await response.text().catch(() => "");
try {
return JSON.parse(text) as T;
} catch {
const contentType =
response.headers.get("content-type")?.split(";", 1)[0] ||
"Content-Type 缺失";
const excerpt = text.trim().slice(0, 2000);
const detail = excerpt ? `\n响应:${excerpt}` : "";
throw new Error(
`${fallback}:服务端返回非 JSON 响应(HTTP ${response.status},${contentType})${detail}`,
);
}
}
Loading
Loading