diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e7fb83d..6706f3d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,7 +42,9 @@ import { listSessions, removeSessionCapability, runSSE, + refreshAgentFeedbackCases, submitMessageFeedback, + upsertCachedAgentFeedbackCase, uploadMedia, getUiConfig, type AdkEvent, @@ -327,6 +329,13 @@ function turnText(turn: Turn): string { .trim(); } +function previousUserTurnText(turns: Turn[], turnIndex: number): string { + for (let index = turnIndex - 1; index >= 0; index -= 1) { + if (turns[index].role === "user") return turnText(turns[index]); + } + return ""; +} + const A2UI_TOOL_NAME = "send_a2ui_json_to_client"; /** Whether a finalized assistant turn has anything visible to render — non-empty @@ -1037,6 +1046,8 @@ export default function App() { const [focusedWorkspaceCaseKind, setFocusedWorkspaceCaseKind] = useState<"good" | "bad">("good"); const [feedbackTargetEventId, setFeedbackTargetEventId] = useState(""); + const [feedbackCasePreview, setFeedbackCasePreview] = + useState(null); const [myAgents, setMyAgents] = useState(false); // A search result may belong to a different agent; remember it so the // agent-switch effect opens it instead of resetting to a fresh chat. @@ -2297,6 +2308,14 @@ export default function App() { eventIds: [...scope.eventIds], }); } + setFeedbackCasePreview((current) => { + if (!current) return current; + return items.some((item) => + item.id === current.id || item.messageId === current.messageId + ) + ? null + : current; + }); } async function ensureSession(activate = true): Promise { @@ -2785,15 +2804,27 @@ export default function App() { region: currentConn.region, } : undefined; - const connectedRuntimeId = currentRuntime?.runtimeId ?? ""; + const connectedRuntimeId = + currentRuntime?.runtimeId ?? + connections.reduce( + (runtimeId, connection) => connection.runtimeId ?? runtimeId, + "", + ); + const currentRuntimeAppName = currentConn + ? currentConn.apps.find((app) => + remoteAppId(currentConn.id, app) === appName + ) ?? agentInfo?.appName ?? currentConn.apps[0] ?? currentConn.name + : ""; const rateAssistantTurn = async ( turn: Turn, rating: MessageFeedbackRating | null, + input = "", ) => { const eventId = turn.meta?.eventId; const sid = sessionId; if (!eventId || !sid || !currentRuntime) return; + const output = turnText(turn); const previousFeedback = turn.meta?.feedback; const optimisticFeedback = { ...previousFeedback, @@ -2809,6 +2840,23 @@ export default function App() { ), ); setFeedbackPendingIds((current) => new Set(current).add(eventId)); + if (currentConn?.runtimeId && currentRuntimeAppName) { + upsertCachedAgentFeedbackCase({ + runtimeId: currentConn.runtimeId, + region: currentConn.region ?? "cn-beijing", + appName: currentRuntimeAppName, + userId, + sessionId: sid, + messageId: eventId, + invocationId: turn.meta?.invocationId, + rating, + input, + output, + createdAt: turn.meta?.ts + ? new Date(turn.meta.ts * 1000).toISOString() + : undefined, + }); + } try { const feedback = await submitMessageFeedback({ appName, @@ -2837,6 +2885,29 @@ export default function App() { : item, ), ); + if (currentConn?.runtimeId && currentRuntimeAppName) { + upsertCachedAgentFeedbackCase({ + runtimeId: currentConn.runtimeId, + region: currentConn.region ?? "cn-beijing", + appName: currentRuntimeAppName, + userId, + sessionId: sid, + messageId: eventId, + invocationId: turn.meta?.invocationId, + rating: feedback.rating, + input, + output, + createdAt: turn.meta?.ts + ? new Date(turn.meta.ts * 1000).toISOString() + : undefined, + }); + refreshAgentFeedbackCases({ + runtimeId: currentConn.runtimeId, + region: currentConn.region ?? "cn-beijing", + appName: currentRuntimeAppName, + pageSize: 100, + }); + } } catch (feedbackError) { setTurnsFor(sid, (current) => current.map((item) => @@ -2845,6 +2916,23 @@ export default function App() { : item, ), ); + if (currentConn?.runtimeId && currentRuntimeAppName) { + upsertCachedAgentFeedbackCase({ + runtimeId: currentConn.runtimeId, + region: currentConn.region ?? "cn-beijing", + appName: currentRuntimeAppName, + userId, + sessionId: sid, + messageId: eventId, + invocationId: turn.meta?.invocationId, + rating: previousFeedback?.rating ?? null, + input, + output, + createdAt: turn.meta?.ts + ? new Date(turn.meta.ts * 1000).toISOString() + : undefined, + }); + } if (viewSidRef.current === sid) { setError( feedbackError instanceof Error @@ -2861,6 +2949,75 @@ export default function App() { } }; + const openCurrentAgentCases = ( + kind?: MessageFeedbackRating | null, + turn?: Turn, + input = "", + ) => { + if (!currentRuntime || !currentConn?.runtimeId) return; + const realApp = currentRuntimeAppName; + const displayName = + currentConn.appLabels?.[realApp] ?? + agentInfo?.name ?? + currentConn.name; + const caseKind = kind === "bad" ? "bad" : "good"; + const eventId = turn?.meta?.eventId ?? ""; + const output = turn ? turnText(turn) : ""; + if ((kind === "good" || kind === "bad") && eventId && output && sessionId) { + const createdAt = turn?.meta?.ts + ? new Date(turn.meta.ts * 1000).toISOString() + : new Date().toISOString(); + setFeedbackCasePreview({ + id: `local:${currentConn.runtimeId}:${sessionId}:${eventId}`, + itemKey: `local:${eventId}`, + kind: caseKind, + input, + output, + referenceOutput: output, + comment: "", + agentName: realApp, + sessionId, + messageId: eventId, + runtimeId: currentConn.runtimeId, + invocationId: turn?.meta?.invocationId ?? "", + userId, + createdAt, + evaluationSetId: "", + evaluationSetName: "", + workspaceId: "", + }); + } else { + setFeedbackCasePreview(null); + } + setFeedbackCaseReturnAgentId(""); + setFeedbackTargetEventId(""); + setAgentDetailTarget({ + id: currentConn.runtimeId, + appName: realApp, + name: displayName, + description: agentInfo?.description || currentConn.name, + createdAt: "—", + runtime: { + runtimeId: currentConn.runtimeId, + region: currentConn.region ?? "cn-beijing", + currentVersion: currentConn.currentVersion, + canDelete: libraryRuntimePermissions[currentConn.runtimeId]?.canDelete === true, + }, + }); + setFocusedDeploymentTaskId(""); + setFocusedWorkspaceAgentId(""); + setFocusedWorkspaceAgentSection("evaluations"); + setFocusedWorkspaceCaseKind(caseKind); + setMyAgents(false); + setManageAgents(true); + setCreateView(null); + setSkillCenter(false); + setAddAgent(false); + setAddMenu(false); + setSearchView(false); + setError(""); + }; + // Selecting an agent starts a fresh chat; any // background stream keeps persisting to its own (old) session. const selectAgent = async (id: string) => { @@ -2943,17 +3100,29 @@ export default function App() { setError(""); }; - const talkToWorkspaceAgent = (id: string) => { + const talkToWorkspaceAgent = async (agent: AgentEntry) => { setFeedbackCaseReturnAgentId(""); setFeedbackTargetEventId(""); - if (agentDetailTarget) { - void connectMyAgent(agentDetailTarget); - return; - } setFocusedDeploymentTaskId(""); setFocusedWorkspaceAgentId(""); + setAgentDetailTarget(null); setManageAgents(false); - void selectAgent(id); + if (agent.runtimeId && agent.id.startsWith("detail:")) { + try { + const agentId = await connectRuntime( + agent.runtimeId, + agent.label, + agent.region ?? "cn-beijing", + agent.currentVersion, + ); + setConnections(loadConnections()); + selectAgent(agentId); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + return; + } + selectAgent(agent.id); }; const selectWorkspaceAgentFromNavbar = (id: string) => { @@ -2973,7 +3142,7 @@ export default function App() { ? { id: `detail:${agentDetailTarget.runtime.runtimeId}`, label: agentDetailTarget.name, - app: agentDetailTarget.name, + app: agentDetailTarget.appName ?? agentDetailTarget.name, remote: true, runtimeApp: detailConnection?.apps[0], runtimeId: agentDetailTarget.runtime.runtimeId, @@ -3363,6 +3532,7 @@ export default function App() { focusedAgentId={detailAgentEntry?.id ?? focusedWorkspaceAgentId} focusedAgentSection={focusedWorkspaceAgentSection} focusedCaseKind={focusedWorkspaceCaseKind} + feedbackCasePreview={feedbackCasePreview} detailOnly={!!detailAgentEntry || !!focusedDeploymentTaskId} onRetryAgents={() => void refreshAgentLibrary()} onAgentOrderChange={saveWorkspaceAgentOrder} @@ -3673,6 +3843,7 @@ export default function App() { const canRate = Boolean( currentRuntime && feedbackEventId && turnText(turn), ); + const feedbackInput = canRate ? previousUserTurnText(turns, i) : ""; return ( void rateAssistantTurn( turn, feedbackRating === "good" ? null : "good", + feedbackInput, )} > void rateAssistantTurn( turn, feedbackRating === "bad" ? null : "bad", + feedbackInput, )} > + )} {!sandboxSession && ( diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index 83f279d2..67b1dd9b 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -414,16 +414,65 @@ const PRIVATE_RUNTIME_UNREACHABLE_MESSAGE = "Runtime 已部署成功,但当前 Studio 无法访问私网 Runtime。请使用已绑定相同 VPC 的 Studio 访问,或改用公网 / 公网+VPC 部署。"; const RUNTIME_ENDPOINT_UNREACHABLE_MESSAGE = "Runtime 已部署成功,但 Studio 暂时无法连接服务。网关域名可能仍在生效,或当前网络/DNS 无法访问该 Runtime,请稍后在智能体管理页重试连接。"; +const RUNTIME_REGION_FALLBACKS = ["cn-beijing", "cn-shanghai"] as const; const RUNTIME_APPS_CACHE_TTL_MS = 30_000; +const RUNTIME_METADATA_CACHE_TTL_MS = 5 * 60 * 1000; +const FEEDBACK_CASES_CACHE_TTL_MS = 60 * 1000; + +interface ClientCacheEntry { + value?: T; + promise?: Promise; + updatedAt: number; +} + +interface ClientCacheOptions { + force?: boolean; +} + const runtimeAppsCache = new Map< string, { apps: string[]; expiresAt: number } >(); +const runtimeAgentInfoCache = new Map>(); +const runtimeDetailCache = new Map>(); +const feedbackCasesCache = + new Map>(); function runtimeAppsCacheKey(runtimeId: string, region: string): string { return `${region}:${runtimeId}`; } +function runtimeRegionCandidates(region?: string): string[] { + const primary = region || "cn-beijing"; + return [ + primary, + ...RUNTIME_REGION_FALLBACKS.filter((candidate) => candidate !== primary), + ]; +} + +function cacheKey(...parts: Array): string { + return parts.map((part) => String(part ?? "")).join("\u0001"); +} + +function freshCacheValue( + cache: Map>, + key: string, + ttlMs: number, +): T | null { + const entry = cache.get(key); + if (!entry?.value) return null; + return Date.now() - entry.updatedAt <= ttlMs ? entry.value : null; +} + +function rememberClientCache( + cache: Map>, + key: string, + value: T, +): T { + cache.set(key, { value, updatedAt: Date.now() }); + return value; +} + async function runtimeProxyErrorCode(response: Response): Promise { try { const payload = (await response.clone().json()) as { @@ -590,21 +639,173 @@ export async function submitMessageFeedback(args: { export async function getAgentFeedbackCases(args: { runtimeId: string; - region: string; + region?: string; appName: string; pageSize?: number; -}): Promise { - const query = new URLSearchParams({ - runtimeId: args.runtimeId, - region: args.region, - appName: args.appName, - page_size: String(args.pageSize ?? 100), +}, options: ClientCacheOptions = {}): Promise { + const key = cacheKey( + args.runtimeId, + args.region || "cn-beijing", + args.appName, + args.pageSize ?? 100, + ); + const cached = freshCacheValue( + feedbackCasesCache, + key, + FEEDBACK_CASES_CACHE_TTL_MS, + ); + if (!options.force && cached) return cached; + const existing = feedbackCasesCache.get(key); + if (!options.force && existing?.promise) return existing.promise; + let lastError: Error | null = null; + const promise = (async () => { + for (const region of runtimeRegionCandidates(args.region)) { + const query = new URLSearchParams({ + runtimeId: args.runtimeId, + region, + appName: args.appName, + page_size: String(args.pageSize ?? 100), + }); + const res = await apiFetch(`/web/evaluation/feedback-cases?${query.toString()}`); + if (res.ok) { + return rememberClientCache( + feedbackCasesCache, + key, + await res.json() as AgentFeedbackCasesResponse, + ); + } + lastError = new Error(await httpErrorMessage(res, "读取评测集失败")); + } + throw lastError ?? new Error("读取评测集失败"); + })(); + feedbackCasesCache.set(key, { + ...existing, + promise, + updatedAt: existing?.updatedAt ?? 0, }); - const res = await apiFetch(`/web/evaluation/feedback-cases?${query.toString()}`); - if (!res.ok) { - throw new Error(await httpErrorMessage(res, "读取评测集失败")); + try { + return await promise; + } finally { + const current = feedbackCasesCache.get(key); + if (current?.promise === promise) { + feedbackCasesCache.set(key, { + value: current.value, + updatedAt: current.updatedAt, + }); + } + } +} + +export function getCachedAgentFeedbackCases(args: { + runtimeId: string; + region?: string; + appName: string; + pageSize?: number; +}): AgentFeedbackCasesResponse | null { + return freshCacheValue( + feedbackCasesCache, + cacheKey( + args.runtimeId, + args.region || "cn-beijing", + args.appName, + args.pageSize ?? 100, + ), + FEEDBACK_CASES_CACHE_TTL_MS, + ); +} + +export function prefetchAgentFeedbackCases(args: { + runtimeId: string; + region?: string; + appName: string; + pageSize?: number; +}): void { + void getAgentFeedbackCases(args).catch(() => {}); +} + +export function refreshAgentFeedbackCases(args: { + runtimeId: string; + region?: string; + appName: string; + pageSize?: number; +}): void { + void getAgentFeedbackCases(args, { force: true }).catch(() => {}); +} + +function feedbackSetsWithCounts( + sets: AgentFeedbackSetSummary[], + items: AgentFeedbackCase[], +): AgentFeedbackSetSummary[] { + return (["good", "bad"] as const).map((kind) => { + const current = sets.find((set) => set.kind === kind); + return { + kind, + evaluationSetId: current?.evaluationSetId ?? null, + evaluationSetName: current?.evaluationSetName ?? null, + workspaceId: current?.workspaceId ?? null, + itemCount: items.filter((item) => item.kind === kind).length, + }; + }); +} + +export function upsertCachedAgentFeedbackCase(args: { + runtimeId: string; + region?: string; + appName: string; + userId: string; + sessionId: string; + messageId: string; + invocationId?: string; + rating: MessageFeedbackRating | null; + input: string; + output: string; + referenceOutput?: string; + createdAt?: string; +}): void { + for (const [key, entry] of feedbackCasesCache.entries()) { + const value = entry.value; + if ( + !value || + value.runtimeId !== args.runtimeId || + value.agentName !== args.appName + ) continue; + const withoutCurrent = value.items.filter((item) => + item.sessionId !== args.sessionId || item.messageId !== args.messageId + ); + const items = args.rating + ? [ + { + id: `local:${args.runtimeId}:${args.sessionId}:${args.messageId}`, + itemKey: `local:${args.messageId}`, + kind: args.rating, + input: args.input, + output: args.output, + referenceOutput: args.referenceOutput ?? args.output, + comment: "", + agentName: args.appName, + sessionId: args.sessionId, + messageId: args.messageId, + runtimeId: args.runtimeId, + invocationId: args.invocationId ?? "", + userId: args.userId, + createdAt: args.createdAt ?? new Date().toISOString(), + evaluationSetId: "", + evaluationSetName: "", + workspaceId: "", + }, + ...withoutCurrent, + ] + : withoutCurrent; + feedbackCasesCache.set(key, { + value: { + ...value, + sets: feedbackSetsWithCounts(value.sets, items), + items, + }, + updatedAt: Date.now(), + promise: entry.promise, + }); } - return res.json(); } export async function deleteAgentFeedbackCases(args: { @@ -613,25 +814,48 @@ export async function deleteAgentFeedbackCases(args: { appName: string; itemIds: string[]; }): Promise<{ deletedCount: number }> { - const res = await apiFetch( - "/web/evaluation/feedback-cases/delete", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - runtimeId: args.runtimeId, - region: args.region, - appName: args.appName, - itemIds: args.itemIds, - }), - }, - {}, - TRANSFER_REQUEST_TIMEOUT_MS, - ); - if (!res.ok) { - throw new Error(await httpErrorMessage(res, "删除评测案例失败")); + let lastError: Error | null = null; + for (const region of runtimeRegionCandidates(args.region)) { + const res = await apiFetch( + "/web/evaluation/feedback-cases/delete", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + runtimeId: args.runtimeId, + region, + appName: args.appName, + itemIds: args.itemIds, + }), + }, + {}, + TRANSFER_REQUEST_TIMEOUT_MS, + ); + if (res.ok) { + const response = await res.json() as { deletedCount: number }; + const deletedIds = new Set(args.itemIds); + for (const [key, entry] of feedbackCasesCache.entries()) { + const value = entry.value; + if ( + !value || + value.runtimeId !== args.runtimeId || + value.agentName !== args.appName + ) continue; + const items = value.items.filter((item) => !deletedIds.has(item.id)); + feedbackCasesCache.set(key, { + value: { + ...value, + sets: feedbackSetsWithCounts(value.sets, items), + items, + }, + updatedAt: Date.now(), + }); + } + return response; + } + lastError = new Error(await httpErrorMessage(res, "删除评测案例失败")); } - return res.json(); + throw lastError ?? new Error("删除评测案例失败"); } export async function deleteSession( @@ -893,6 +1117,8 @@ export interface FrontendInvocation { /** Introspected metadata for an agent app, served locally or by Agent Server. */ export interface AgentInfo { + /** Real ADK app id used in runtime proxy paths; display names may differ. */ + appName?: string; name: string; description: string; type?: AgentNodeType; @@ -1130,6 +1356,7 @@ async function fetchAgentInfo( } } return { + appName: app, name: info.name ?? app, description: info.description ?? "", type: info.type, @@ -1151,18 +1378,101 @@ export async function getAgentInfo(appName: string): Promise { } /** Read Agent metadata for a Runtime without connecting or persisting it. */ -export async function getRuntimeAgentInfo( +async function fetchRuntimeAgentInfo( runtimeId: string, region: string, knownApp?: string, ): Promise { - const ep = { runtimeId, region }; - const cacheKey = runtimeAppsCacheKey(runtimeId, region); - const cached = runtimeAppsCache.get(cacheKey); - if (cached && cached.expiresAt <= Date.now()) runtimeAppsCache.delete(cacheKey); - const app = knownApp || cached?.apps[0] || (await fetchRemoteApps("", "", ep))[0]; - if (!app) throw new Error("该 Runtime 未提供可预览的 Agent。"); - return fetchAgentInfo(app, ep); + let lastError: Error | null = null; + for (const candidate of runtimeRegionCandidates(region)) { + const ep = { runtimeId, region: candidate }; + try { + const appCacheKey = runtimeAppsCacheKey(runtimeId, candidate); + const cached = runtimeAppsCache.get(appCacheKey); + if (cached && cached.expiresAt <= Date.now()) { + runtimeAppsCache.delete(appCacheKey); + } + const freshCached = runtimeAppsCache.get(appCacheKey); + const app = + knownApp || + freshCached?.apps[0] || + (await fetchRemoteApps("", "", ep))[0]; + if (!app) throw new Error("该 Runtime 未提供可预览的 Agent。"); + return fetchAgentInfo(app, ep); + } catch (error) { + if ( + error instanceof RuntimeAccessDeniedError || + (error instanceof RuntimeProbeError && !error.unsupported) + ) { + throw error; + } + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + throw lastError ?? new Error("该 Runtime 未提供可预览的 Agent。"); +} + +/** Read Agent metadata for a Runtime without connecting or persisting it. */ +export async function getRuntimeAgentInfo( + runtimeId: string, + region: string, + knownAppOrOptions: string | ClientCacheOptions = {}, + maybeOptions: ClientCacheOptions = {}, +): Promise { + const knownApp = typeof knownAppOrOptions === "string" + ? knownAppOrOptions + : undefined; + const options = typeof knownAppOrOptions === "string" + ? maybeOptions + : knownAppOrOptions; + const key = cacheKey(runtimeId, region || "cn-beijing", knownApp ?? ""); + const cached = freshCacheValue( + runtimeAgentInfoCache, + key, + RUNTIME_METADATA_CACHE_TTL_MS, + ); + if (!options.force && cached) return cached; + const existing = runtimeAgentInfoCache.get(key); + if (!options.force && existing?.promise) return existing.promise; + const promise = fetchRuntimeAgentInfo(runtimeId, region, knownApp).then((info) => + rememberClientCache(runtimeAgentInfoCache, key, info), + ); + runtimeAgentInfoCache.set(key, { + ...existing, + promise, + updatedAt: existing?.updatedAt ?? 0, + }); + try { + return await promise; + } finally { + const current = runtimeAgentInfoCache.get(key); + if (current?.promise === promise) { + runtimeAgentInfoCache.set(key, { + value: current.value, + updatedAt: current.updatedAt, + }); + } + } +} + +export function getCachedRuntimeAgentInfo( + runtimeId: string, + region: string, + knownApp = "", +): AgentInfo | null { + return freshCacheValue( + runtimeAgentInfoCache, + cacheKey(runtimeId, region || "cn-beijing", knownApp), + RUNTIME_METADATA_CACHE_TTL_MS, + ); +} + +export function prefetchRuntimeAgentInfo( + runtimeId: string, + region: string, + knownApp = "", +): void { + void getRuntimeAgentInfo(runtimeId, region, knownApp).catch(() => {}); } /** One web-search hit (Volcengine WebSearch WebItem, trimmed for the UI). */ @@ -1845,17 +2155,73 @@ export interface RuntimeDetail { } /** Fetch a runtime's control-plane detail (config/status/envs). */ -export async function getRuntimeDetail( +async function fetchRuntimeDetail( runtimeId: string, region: string, ): Promise { - const res = await apiFetch( - `/web/runtime-detail?runtimeId=${encodeURIComponent(runtimeId)}®ion=${encodeURIComponent(region)}`, + let lastError: Error | null = null; + for (const candidate of runtimeRegionCandidates(region)) { + const res = await apiFetch( + `/web/runtime-detail?runtimeId=${encodeURIComponent(runtimeId)}®ion=${encodeURIComponent(candidate)}`, + ); + if (res.ok) return res.json(); + lastError = new Error(await httpErrorMessage(res, "加载 Runtime 详情失败")); + } + throw lastError ?? new Error("加载 Runtime 详情失败"); +} + +/** Fetch a runtime's control-plane detail (config/status/envs). */ +export async function getRuntimeDetail( + runtimeId: string, + region = "cn-beijing", + options: ClientCacheOptions = {}, +): Promise { + const key = cacheKey(runtimeId, region || "cn-beijing"); + const cached = freshCacheValue( + runtimeDetailCache, + key, + RUNTIME_METADATA_CACHE_TTL_MS, ); - if (!res.ok) { - throw new Error(await httpErrorMessage(res, "加载 Runtime 详情失败")); + if (!options.force && cached) return cached; + const existing = runtimeDetailCache.get(key); + if (!options.force && existing?.promise) return existing.promise; + const promise = fetchRuntimeDetail(runtimeId, region).then((detail) => + rememberClientCache(runtimeDetailCache, key, detail), + ); + runtimeDetailCache.set(key, { + ...existing, + promise, + updatedAt: existing?.updatedAt ?? 0, + }); + try { + return await promise; + } finally { + const current = runtimeDetailCache.get(key); + if (current?.promise === promise) { + runtimeDetailCache.set(key, { + value: current.value, + updatedAt: current.updatedAt, + }); + } } - return res.json(); +} + +export function getCachedRuntimeDetail( + runtimeId: string, + region = "cn-beijing", +): RuntimeDetail | null { + return freshCacheValue( + runtimeDetailCache, + cacheKey(runtimeId, region || "cn-beijing"), + RUNTIME_METADATA_CACHE_TTL_MS, + ); +} + +export function prefetchRuntimeDetail( + runtimeId: string, + region = "cn-beijing", +): void { + void getRuntimeDetail(runtimeId, region).catch(() => {}); } export interface GeneratedAgentTestRun { diff --git a/frontend/src/adk/connections.ts b/frontend/src/adk/connections.ts index 272e28eb..d22c3575 100644 --- a/frontend/src/adk/connections.ts +++ b/frontend/src/adk/connections.ts @@ -8,6 +8,7 @@ import { probeRuntimeApps, registerRemoteApp, RuntimeAccessDeniedError, + RuntimeProbeError, } from "./client"; export interface RemoteConnection { @@ -42,6 +43,15 @@ export interface AgentEntry { } const STORAGE_KEY = "veadk_agentkit_connections"; +const RUNTIME_REGION_FALLBACKS = ["cn-beijing", "cn-shanghai"] as const; + +function runtimeRegionCandidates(region: string): string[] { + const primary = region || "cn-beijing"; + return [ + primary, + ...RUNTIME_REGION_FALLBACKS.filter((candidate) => candidate !== primary), + ]; +} export function loadConnections(): RemoteConnection[] { try { @@ -127,24 +137,39 @@ export async function connectRuntime( region: string, currentVersion?: number | null, ): Promise { - let apps: string[] | null; - try { - apps = await probeRuntimeApps(runtimeId, region); - } catch (error) { - if (error instanceof RuntimeAccessDeniedError) { - removeRuntimeConnection(runtimeId); + let apps: string[] | null = null; + let resolvedRegion = region || "cn-beijing"; + let unsupportedError: RuntimeProbeError | null = null; + for (const candidate of runtimeRegionCandidates(region)) { + try { + const probedApps = await probeRuntimeApps(runtimeId, candidate); + if (probedApps && probedApps.length > 0) { + apps = probedApps; + resolvedRegion = candidate; + break; + } + } catch (error) { + if (error instanceof RuntimeAccessDeniedError) { + removeRuntimeConnection(runtimeId); + throw error; + } + if (error instanceof RuntimeProbeError && error.unsupported) { + unsupportedError = error; + continue; + } + throw error; } - throw error; } if (!apps || apps.length === 0) { removeRuntimeConnection(runtimeId); + if (unsupportedError) throw unsupportedError; throw new Error("该 Runtime 暂不支持连接,请确认服务已正常运行。"); } const labels = Object.fromEntries(apps.map((app) => [app, name])); const connection = addRuntimeConnection( runtimeId, name, - region, + resolvedRegion, apps, labels, currentVersion, diff --git a/frontend/src/create/AgentBuildCanvas.tsx b/frontend/src/create/AgentBuildCanvas.tsx index 96349e96..9e5bd06a 100644 --- a/frontend/src/create/AgentBuildCanvas.tsx +++ b/frontend/src/create/AgentBuildCanvas.tsx @@ -1041,9 +1041,20 @@ function AgentBuildCanvasInner({ : { padding: 0.14, minZoom: 0.42, maxZoom: 1.1 }, [compactCanvas, readOnly], ); - const fitAfterLayout = useCallback(() => { + const fitAfterLayout = useCallback((attempt = 0) => { window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => void fitView(fitOptions)); + window.requestAnimationFrame(() => { + const container = canvasRef.current; + if ( + container && + (container.clientWidth === 0 || container.clientHeight === 0) && + attempt < 8 + ) { + fitAfterLayout(attempt + 1); + return; + } + void fitView(fitOptions); + }); }); }, [fitOptions, fitView]); @@ -1137,6 +1148,7 @@ function AgentBuildCanvasInner({ zoomOnScroll={!readOnly || interactivePreview} fitView fitViewOptions={fitOptions} + onInit={() => fitAfterLayout()} minZoom={readOnly ? 0.05 : 0.35} maxZoom={1.6} proOptions={{ hideAttribution: true }} diff --git a/frontend/src/ui/AgentSelector.tsx b/frontend/src/ui/AgentSelector.tsx index 2987bdba..50586d2b 100644 --- a/frontend/src/ui/AgentSelector.tsx +++ b/frontend/src/ui/AgentSelector.tsx @@ -17,6 +17,8 @@ import { X, } from "lucide-react"; import { + getCachedRuntimeAgentInfo, + getCachedRuntimeDetail, getRuntimeAgentInfo, getRuntimeDetail, getRuntimes, @@ -635,21 +637,27 @@ function RuntimePreviewPanel({ /** Agent Server metadata for a hovered Runtime. This request is intentionally * isolated from Runtime detail: either may fail without hiding the other. */ function AgentInfoContent({ runtime }: { runtime: SelectedRuntime }) { - const [info, setInfo] = useState(null); - const [loading, setLoading] = useState(true); + const [info, setInfo] = useState(() => + getCachedRuntimeAgentInfo(runtime.runtimeId, runtime.region), + ); + const [loading, setLoading] = useState(() => + !getCachedRuntimeAgentInfo(runtime.runtimeId, runtime.region), + ); const [error, setError] = useState(""); const runtimeId = runtime.runtimeId; const runtimeRegion = runtime.region; useEffect(() => { let alive = true; - setInfo(null); - setLoading(true); + const cached = getCachedRuntimeAgentInfo(runtimeId, runtimeRegion); + setInfo(cached); + setLoading(!cached); setError(""); - getRuntimeAgentInfo(runtimeId, runtimeRegion) + getRuntimeAgentInfo(runtimeId, runtimeRegion, { force: Boolean(cached) }) .then((nextInfo) => alive && setInfo(nextInfo)) .catch((e) => { if (!alive) return; + if (cached) return; const message = e instanceof Error ? e.message : String(e); setError(runtimeMetadataErrorMessage(message)); }) @@ -810,22 +818,28 @@ function InfoChipSection({ /** Control-plane detail for the hovered Runtime. */ function RuntimeDetailContent({ runtime }: { runtime: SelectedRuntime }) { - const [detail, setDetail] = useState(null); - const [loading, setLoading] = useState(true); + const [detail, setDetail] = useState(() => + getCachedRuntimeDetail(runtime.runtimeId, runtime.region), + ); + const [loading, setLoading] = useState(() => + !getCachedRuntimeDetail(runtime.runtimeId, runtime.region), + ); const [error, setError] = useState(""); const runtimeId = runtime.runtimeId; const runtimeRegion = runtime.region; useEffect(() => { let alive = true; - setLoading(true); + const cached = getCachedRuntimeDetail(runtimeId, runtimeRegion); + setDetail(cached); + setLoading(!cached); setError(""); - setDetail(null); - getRuntimeDetail(runtimeId, runtimeRegion) + getRuntimeDetail(runtimeId, runtimeRegion, { force: Boolean(cached) }) .then((d) => alive && setDetail(d)) .catch( (e) => alive && + !cached && setError( runtimeMetadataErrorMessage( e instanceof Error ? e.message : String(e), diff --git a/frontend/src/ui/AgentWorkspace.tsx b/frontend/src/ui/AgentWorkspace.tsx index 09eb70c1..1174c622 100644 --- a/frontend/src/ui/AgentWorkspace.tsx +++ b/frontend/src/ui/AgentWorkspace.tsx @@ -21,9 +21,16 @@ import { } from "lucide-react"; import { deleteAgentFeedbackCases, + getCachedAgentFeedbackCases, + getCachedRuntimeAgentInfo, + getCachedRuntimeDetail, getAgentFeedbackCases, getRuntimeAgentInfo, getRuntimeDetail, + prefetchAgentFeedbackCases, + prefetchRuntimeAgentInfo, + prefetchRuntimeDetail, + type AgentFeedbackCasesResponse, type AgentFeedbackCase, type AgentFeedbackSetSummary, type AgentInfo, @@ -286,6 +293,19 @@ function feedbackSetFor( return sets.find((set) => set.kind === kind); } +function feedbackCasesFromResponse( + response: AgentFeedbackCasesResponse, +): AgentCase[] { + return response.items + .map((item) => ({ + ...item, + tag: item.kind === "good" ? "Good case" : "Bad case", + })) + .sort((left, right) => ( + caseTimeValue(right.createdAt) - caseTimeValue(left.createdAt) + )); +} + function canvasDraftKey(draft: AgentDraft): string { const visit = (node: AgentDraft): unknown => [ node.name, @@ -545,13 +565,14 @@ export interface AgentWorkspaceProps { focusedAgentId?: string; focusedAgentSection?: AgentSection; focusedCaseKind?: CaseKind; + feedbackCasePreview?: AgentFeedbackCase | null; detailOnly?: boolean; onRetryAgents?: () => void; onAgentOrderChange?: (agentIds: string[]) => void; onDeleteAgents?: (agents: AgentEntry[]) => Promise; onDeleteDrafts?: (drafts: WorkspaceAgentDraft[]) => void; onSelectAgent: (id: string) => void; - onTalkAgent?: (id: string) => void; + onTalkAgent?: (agent: AgentEntry) => void; onOpenFeedbackCase?: (item: AgentFeedbackCase) => void | Promise; onFeedbackCasesDeleted?: (items: AgentFeedbackCase[]) => void; onCreateAgent: () => void; @@ -575,6 +596,7 @@ export function AgentWorkspace({ focusedAgentId = "", focusedAgentSection = "basic", focusedCaseKind = "good", + feedbackCasePreview = null, detailOnly = false, onRetryAgents, onAgentOrderChange, @@ -723,6 +745,8 @@ export function AgentWorkspace({ : activeAgentId && agentInfoAgentId === activeAgentId ? agentInfo : null; + const selectedAgentAppName = + selectedAgentInfo?.appName || selectedAgent?.runtimeApp || selectedAgent?.app || ""; const listedAgents = useMemo(() => { const originalOrder = new Map(agents.map((agent, index) => [agent.id, index])); const savedOrder = new Map(agentOrder.map((id, index) => [id, index])); @@ -841,9 +865,6 @@ export function AgentWorkspace({ const executionFlowKey = selectedAgentInfo ? `runtime:${selectedAgent?.runtimeId ?? selectedAgentInfo.name}:v${runtimeVersionKey}:${draftFlowKey}` : `draft:${selectedPendingTask?.id ?? selectedDraft?.id ?? selectedAgent?.id ?? selectedName}:${draftFlowKey}`; - const loadingExecutionFlow = Boolean( - detailOnly && selectedAgent?.runtimeId && !detailAgentInfoResolved, - ); useEffect(() => { if (!focusedDeploymentTaskId) return; const focusedTask = deploymentTasks.find( @@ -881,23 +902,63 @@ export function AgentWorkspace({ } }, [agents, focusedAgentId, focusedAgentSection, focusedCaseKind]); + useEffect(() => { + for (const agent of listedAgents.slice(0, 8)) { + if (!agent.runtimeId) continue; + const region = agent.region ?? "cn-beijing"; + prefetchRuntimeDetail(agent.runtimeId, region); + prefetchRuntimeAgentInfo(agent.runtimeId, region, agent.runtimeApp ?? ""); + void getRuntimeAgentInfo(agent.runtimeId, region, agent.runtimeApp ?? "") + .then((info) => { + const appName = info.appName || agent.app; + if (!appName) return; + prefetchAgentFeedbackCases({ + runtimeId: agent.runtimeId ?? "", + region, + appName, + pageSize: 100, + }); + }) + .catch(() => {}); + } + }, [listedAgents]); + + useEffect(() => { + if (!selectedAgent?.runtimeId || !selectedAgentAppName) return; + prefetchAgentFeedbackCases({ + runtimeId: selectedAgent.runtimeId, + region: selectedAgent.region ?? "cn-beijing", + appName: selectedAgentAppName, + pageSize: 100, + }); + }, [ + selectedAgentAppName, + selectedAgent?.region, + selectedAgent?.runtimeId, + ]); + useEffect(() => { let cancelled = false; - setDetailAgentInfo(null); - setDetailAgentInfoResolved( - !detailOnly || !selectedAgent?.runtimeId || !selectedAgent.region, - ); - if (!detailOnly || !selectedAgent?.runtimeId || !selectedAgent.region) return; + const runtimeId = selectedAgent?.runtimeId ?? ""; + const region = selectedAgent?.region ?? "cn-beijing"; + const knownApp = selectedAgent?.runtimeApp ?? ""; + const cached = runtimeId + ? getCachedRuntimeAgentInfo(runtimeId, region, knownApp) + : null; + setDetailAgentInfo(cached); + setDetailAgentInfoResolved(Boolean(cached) || !detailOnly || !runtimeId); + if (!detailOnly || !runtimeId) return; void getRuntimeAgentInfo( - selectedAgent.runtimeId, - selectedAgent.region, - selectedAgent.runtimeApp, + runtimeId, + region, + knownApp, + { force: true }, ) .then((info) => { if (!cancelled) setDetailAgentInfo(info); }) .catch(() => { - if (!cancelled) setDetailAgentInfo(null); + if (!cancelled && !cached) setDetailAgentInfo(null); }) .finally(() => { if (!cancelled) setDetailAgentInfoResolved(true); @@ -915,17 +976,23 @@ export function AgentWorkspace({ useEffect(() => { let cancelled = false; - setRuntimeDetail(null); - if (!selectedAgent?.runtimeId || !selectedAgent.region) return; + const runtimeId = selectedAgent?.runtimeId ?? ""; + const region = selectedAgent?.region ?? "cn-beijing"; + const cached = runtimeId + ? getCachedRuntimeDetail(runtimeId, region) + : null; + setRuntimeDetail(cached); + if (!runtimeId) return; void getRuntimeDetail( - selectedAgent.runtimeId, - selectedAgent.region, + runtimeId, + region, + { force: true }, ) .then((detail) => { if (!cancelled) setRuntimeDetail(detail); }) .catch(() => { - if (!cancelled) setRuntimeDetail(null); + if (!cancelled && !cached) setRuntimeDetail(null); }); return () => { cancelled = true; @@ -938,33 +1005,38 @@ export function AgentWorkspace({ useEffect(() => { let cancelled = false; - setFeedbackCases([]); - setFeedbackSets([]); + const runtimeId = selectedAgent?.runtimeId ?? ""; + const region = selectedAgent?.region ?? "cn-beijing"; + const cached = runtimeId && selectedAgentAppName + ? getCachedAgentFeedbackCases({ + runtimeId, + region, + appName: selectedAgentAppName, + pageSize: 100, + }) + : null; + setFeedbackCases(cached ? feedbackCasesFromResponse(cached) : []); + setFeedbackSets(cached?.sets ?? []); setFeedbackCasesError(""); - if (section !== "evaluations" || !selectedAgent?.runtimeId || !selectedAgent.region) { + if (section !== "evaluations" || !runtimeId) { setFeedbackCasesLoading(false); return; } - setFeedbackCasesLoading(true); + if (detailOnly && !selectedAgentAppName) { + setFeedbackCasesLoading(!detailAgentInfoResolved); + return; + } + setFeedbackCasesLoading(!cached); void getAgentFeedbackCases({ - runtimeId: selectedAgent.runtimeId, - region: selectedAgent.region, - appName: selectedAgent.app, + runtimeId, + region, + appName: selectedAgentAppName, pageSize: 100, - }) + }, { force: true }) .then((response) => { if (cancelled) return; setFeedbackSets(response.sets); - setFeedbackCases( - response.items - .map((item) => ({ - ...item, - tag: item.kind === "good" ? "Good case" : "Bad case", - })) - .sort((left, right) => ( - caseTimeValue(right.createdAt) - caseTimeValue(left.createdAt) - )), - ); + setFeedbackCases(feedbackCasesFromResponse(response)); }) .catch((cause) => { if (!cancelled) { @@ -978,9 +1050,12 @@ export function AgentWorkspace({ cancelled = true; }; }, [ + detailAgentInfoResolved, + detailOnly, feedbackReloadToken, section, - selectedAgent?.app, + selectedAgentAppName, + selectedAgentInfo?.appName, selectedAgent?.region, selectedAgent?.runtimeId, ]); @@ -1026,7 +1101,30 @@ export function AgentWorkspace({ }); }, [filteredDrafts]); - const cases = selectedAgent?.runtimeId ? feedbackCases : DEFAULT_CASES; + const previewCase = useMemo(() => { + if (!feedbackCasePreview || !selectedAgent?.runtimeId) return null; + if (feedbackCasePreview.runtimeId !== selectedAgent.runtimeId) return null; + if ( + selectedAgentAppName && + feedbackCasePreview.agentName && + feedbackCasePreview.agentName !== selectedAgentAppName + ) return null; + return { + ...feedbackCasePreview, + tag: feedbackCasePreview.kind === "good" ? "Good case" : "Bad case", + }; + }, [feedbackCasePreview, selectedAgent?.runtimeId, selectedAgentAppName]); + const cases = useMemo(() => { + if (!selectedAgent?.runtimeId) return DEFAULT_CASES; + if (!previewCase) return feedbackCases; + return [ + previewCase, + ...feedbackCases.filter((item) => + item.id !== previewCase.id && + (!item.messageId || item.messageId !== previewCase.messageId) + ), + ]; + }, [feedbackCases, previewCase, selectedAgent?.runtimeId]); const visibleCases = cases.filter((item) => { if (item.kind !== caseFilter) return false; const keyword = caseQuery.trim().toLowerCase(); @@ -1103,7 +1201,7 @@ export function AgentWorkspace({ const deleteCases = async (items: AgentCase[]) => { if ( !selectedAgent?.runtimeId || - !selectedAgent.region || + !selectedAgentAppName || deletingCases || items.length === 0 ) return; @@ -1118,8 +1216,8 @@ export function AgentWorkspace({ try { await deleteAgentFeedbackCases({ runtimeId: selectedAgent.runtimeId, - region: selectedAgent.region, - appName: selectedAgent.app, + region: selectedAgent.region ?? "cn-beijing", + appName: selectedAgentAppName, itemIds: ids, }); const deletedByKind = new Map(); @@ -1825,25 +1923,18 @@ export function AgentWorkspace({ 执行流程
- {loadingExecutionFlow ? ( -
-
- ) : ( - undefined} - onAdd={() => undefined} - onInsert={() => undefined} - onDelete={() => undefined} - readOnly - interactivePreview - /> - )} + undefined} + onAdd={() => undefined} + onInsert={() => undefined} + onDelete={() => undefined} + readOnly + interactivePreview + />
@@ -1924,8 +2015,8 @@ export function AgentWorkspace({
{(["good", "bad"] as const).map((kind) => { const set = feedbackSetFor(feedbackSets, kind); - const count = set?.itemCount ?? - feedbackCases.filter((item) => item.kind === kind).length; + const localCount = cases.filter((item) => item.kind === kind).length; + const count = previewCase ? localCount : set?.itemCount ?? localCount; return (
) : ( cases.map((item) => { + const isLocalPreview = item.id.startsWith("local:"); const isSelected = selectedCaseIds?.has(item.id) ?? false; const isExpanded = expandedCaseIds?.has(item.id) ?? false; const outputLength = item.output.length + item.referenceOutput.length; const canExpand = outputLength > 220; + const canDeleteCase = canDelete && !isLocalPreview; return (
{ if (selectionMode) { - onToggleCase?.(item); + if (canDeleteCase) onToggleCase?.(item); return; } onOpenCase?.(item); @@ -2171,7 +2264,7 @@ function CaseTable({ if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (selectionMode) { - onToggleCase?.(item); + if (canDeleteCase) onToggleCase?.(item); } else { onOpenCase?.(item); } @@ -2179,7 +2272,7 @@ function CaseTable({ >
- {selectionMode && ( + {selectionMode && canDeleteCase && ( - {canDelete && ( + {canDeleteCase && (