diff --git a/frontend/README.md b/frontend/README.md index 55934ea0..2894d46d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -18,7 +18,9 @@ server that `veadk frontend` launches — no separate backend. a real two-model A/B run in independent AgentKit CodeEnv sessions. Skill progress resumes from Sandbox state if the creation stream is interrupted; completed candidates can be compared, downloaded as ZIP files, and added to - AgentKit. + AgentKit. Connected Harness agents expose supported image, video, and + presentation task types; Studio mounts only missing task tools for the + current session and preserves tools already supplied by the Agent. - **Reasoning & tool calls** shown inline (collapsible "thinking", tool blocks). - **Agent context rail** keeps the selected Agent's description, model, tools, skills, and optional live multi-Agent topology together in the conversation's @@ -59,13 +61,17 @@ server that `veadk frontend` launches — no separate backend. - **Runtime management**: inspect or delete deployed runtimes, or connect one directly so the global Agent selector switches to that Runtime. The cloud selector gives each two-line Runtime row explicit connect and info actions; - the info action opens a tabbed Agent/Runtime panel. Studio distinguishes its + the info action opens a tabbed Agent/Runtime panel. The Agent directory loads + one selected region at a time, defaults to Beijing, and carries the Runtime's + region through details, connection, update, evaluation, and deletion. Studio + distinguishes its own ownership checks from Agent Server compatibility and authentication failures when a connection cannot be established. Long descriptions, names, component summaries, IDs, and environment values stay inside the scrollable panel. - **Custom-agent workbench**: configure an agent with a rich Markdown system-prompt editor (including heading and list shortcuts), then debug with - expandable, copyable runner error details and review. Long descriptions and + expandable, copyable runner error details, per-result Trace inspection, and + review. Long descriptions and prompts scroll within bounded editors, while the sidebar stays pinned to the viewport. On narrow desktop windows, the structure, configuration, and debug panels stack vertically instead of squeezing the form. The deployment page diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ef6de9c..5b794915 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,8 @@ import { deleteMedia, deleteSessionMedia, deleteSession, + downloadArtifact, + previewArtifact, getAgentInfo, getSessionCapabilities, getSession, @@ -108,7 +110,11 @@ import { StudioUpdateControl } from "./ui/StudioUpdateControl"; import { createSkillJob, deleteSkillJob } from "./ui/skill-create/api"; import { SkillCreateWorkspace } from "./ui/skill-create/SkillCreateWorkspace"; import { SKILL_MODELS, type SkillCreationJob } from "./ui/skill-create/types"; -import type { NewChatMode } from "./ui/new-chat-modes/types"; +import type { NewChatMode, NewChatTask } from "./ui/new-chat-modes/types"; +import { + NEW_CHAT_TASK_OPTIONAL_TOOLS, + NEW_CHAT_TASK_TOOLS, +} from "./ui/new-chat-modes/taskTools"; import { sandboxClient, type SandboxSession as SandboxSessionInfo, @@ -130,6 +136,35 @@ import { FeedbackUpIcon, } from "./ui/icons/FeedbackIcons"; +interface NewChatCapabilitiesState { + agentId?: string; + ready?: boolean; + harnessEnabled?: boolean; + builtinTools?: string[]; + temporaryEnabled?: boolean; + skillCreateEnabled?: boolean; +} + +async function probeNewChatCapabilities( + agentId: string, +): Promise { + const [sandboxResult, skillResult, harnessResult] = await Promise.allSettled([ + getSandboxCapability(), + getSkillCreatorCapability(), + listSessionBuiltinTools(agentId), + ]); + return { + agentId, + ready: true, + harnessEnabled: harnessResult.status === "fulfilled", + builtinTools: harnessResult.status === "fulfilled" ? harnessResult.value : [], + temporaryEnabled: + sandboxResult.status === "fulfilled" && sandboxResult.value.enabled, + skillCreateEnabled: + skillResult.status === "fulfilled" && skillResult.value.enabled, + }; +} + // Breadcrumb root label for the create flow and the per-mode leaf labels. const CREATE_ROOT = "创建 Agent"; type CreateMode = QuickCreateKind | "package"; @@ -298,6 +333,7 @@ function turnHasVisibleContent(turn: Turn): boolean { return turn.blocks.some((b) => { if (b.kind === "text") return b.text.trim().length > 0; if (b.kind === "attachment") return b.files.length > 0; + if (b.kind === "artifact") 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]); @@ -654,10 +690,14 @@ export default function App() { })); const [input, setInput] = useState(""); const [newChatMode, setNewChatMode] = useState("agent"); - const [newChatCapabilities, setNewChatCapabilities] = useState<{ - temporaryEnabled?: boolean; - skillCreateEnabled?: boolean; - }>({}); + const [newChatTask, setNewChatTask] = useState(null); + const [newChatCapabilities, setNewChatCapabilities] = + useState({}); + const newChatCapabilitiesCacheRef = useRef( + new Map(), + ); + const newChatCapabilitiesReady = + newChatCapabilities.ready === true && newChatCapabilities.agentId === appName; const [skillJob, setSkillJob] = useState(null); const [skillCreating, setSkillCreating] = useState(false); const skillCreationRunRef = useRef(0); @@ -987,6 +1027,7 @@ export default function App() { region: string; currentVersion?: number | null; } | null>(null); + const [newRuntimeRegion, setNewRuntimeRegion] = useState("cn-beijing"); const [focusedDeploymentTaskId, setFocusedDeploymentTaskId] = useState(""); const [focusedWorkspaceAgentId, setFocusedWorkspaceAgentId] = useState(""); const [agentDetailTarget, setAgentDetailTarget] = @@ -1109,7 +1150,8 @@ export default function App() { const failures: string[] = []; for (const agent of targets) { try { - await deleteRuntime(agent.runtimeId, agent.region ?? "cn-beijing"); + if (!agent.region) throw new Error("Runtime 缺少地域信息,无法删除"); + await deleteRuntime(agent.runtimeId, agent.region); removeRuntimeConnection(agent.runtimeId); deletedRuntimeIds.add(agent.runtimeId); deletedAgentIds.add(agent.id); @@ -1236,7 +1278,7 @@ export default function App() { const finishDeployment = useCallback( async (result: DeployResult) => { if (!result.runtimeId) throw new Error("部署完成,但未返回 Runtime ID。"); - const fallbackRegion = runtimeUpdateTarget?.region ?? "cn-beijing"; + const fallbackRegion = runtimeUpdateTarget?.region ?? newRuntimeRegion; const agentId = await connectRuntime( result.runtimeId, result.agentName, @@ -1245,6 +1287,9 @@ export default function App() { ); setConnections(loadConnections()); setAgentInfoRefreshKey((key) => key + 1); + const capabilities = await probeNewChatCapabilities(agentId); + newChatCapabilitiesCacheRef.current.set(agentId, capabilities); + setNewChatCapabilities(capabilities); setLibraryRuntimeIds((current) => { const next = new Set(current ?? []); next.add(result.runtimeId!); @@ -1260,7 +1305,7 @@ export default function App() { setManageAgents(true); setAppName(agentId); }, - [editingDraftId, removeWorkspaceDraft, runtimeUpdateTarget], + [editingDraftId, newRuntimeRegion, removeWorkspaceDraft, runtimeUpdateTarget], ); const scrollRef = useRef(null); const turnNodeRefs = useRef>(new Map()); @@ -1437,27 +1482,26 @@ export default function App() { }, [localMode, userId]); useEffect(() => { - if (authStatus !== "authenticated" || !userId) { + if (authStatus !== "authenticated" || !userId || !appName) { setNewChatCapabilities({}); return; } + const cached = newChatCapabilitiesCacheRef.current.get(appName); + if (cached) { + setNewChatCapabilities(cached); + return; + } let cancelled = false; - void Promise.allSettled([ - getSandboxCapability(), - getSkillCreatorCapability(), - ]).then(([sandboxResult, skillResult]) => { + setNewChatCapabilities({}); + void probeNewChatCapabilities(appName).then((capabilities) => { if (cancelled) return; - setNewChatCapabilities({ - temporaryEnabled: - sandboxResult.status === "fulfilled" && sandboxResult.value.enabled, - skillCreateEnabled: - skillResult.status === "fulfilled" && skillResult.value.enabled, - }); + newChatCapabilitiesCacheRef.current.set(appName, capabilities); + setNewChatCapabilities(capabilities); }); return () => { cancelled = true; }; - }, [authStatus, userId]); + }, [appName, authStatus, userId]); useEffect(() => { if (authStatus !== "authenticated" || !userId) { @@ -1935,6 +1979,7 @@ export default function App() { setAgentInfoOpen(false); setGreeting(pickGreeting()); setNewChatMode("agent"); + setNewChatTask(null); discardSkillCreation(); setSkillCreating(false); const abandonedSession = sessionId && persistentTurns.length === 0 && attachments.length > 0 @@ -2012,6 +2057,7 @@ export default function App() { setInitializingSession(false); setPendingTurns([]); setNewChatMode("agent"); + setNewChatTask(null); discardSkillCreation(); setInvocation(emptyInvocation()); setSessionCapabilities(null); @@ -2286,6 +2332,7 @@ export default function App() { setInitializingSession(true); } + const selectedTask = newChatTask; let sid: string; try { sid = await ensureSession(!createsSession); @@ -2300,6 +2347,40 @@ export default function App() { return; } + let runWithSessionCapabilities = sessionCapabilities !== null; + if (selectedTask) { + try { + let updated = await getSessionCapabilities(appName, userId, sid); + const optionalTools = NEW_CHAT_TASK_OPTIONAL_TOOLS[selectedTask].filter( + (toolName) => newChatCapabilities.builtinTools?.includes(toolName), + ); + for (const toolName of [ + ...NEW_CHAT_TASK_TOOLS[selectedTask], + ...optionalTools, + ]) { + if (updated.tools.some((tool) => tool.name === toolName)) continue; + updated = await addSessionCapability( + appName, + userId, + sid, + { kind: "tool", name: toolName }, + updated.revision, + ); + } + setSessionCapabilities(updated); + runWithSessionCapabilities = true; + } catch (e) { + if (createsSession) { + setPendingTurns([]); + setInitializingSession(false); + setInput(text); + setInvocation(selectedInvocation); + } + setError(`任务能力挂载失败:${String(e)}`); + return; + } + } + setTurnsFor(sid, (current) => createsSession ? optimisticTurns : [...current, ...optimisticTurns], ); @@ -2336,7 +2417,7 @@ export default function App() { attachments: atts, invocation: selectedInvocation, signal: ctrl.signal, - sessionCapabilities: sessionCapabilities !== null, + sessionCapabilities: runWithSessionCapabilities, })) { if (ctrl.signal.aborted) break; const errMsg = event.error ?? event.errorMessage ?? event.error_message; @@ -2582,11 +2663,11 @@ export default function App() { (c) => c.runtimeId && c.apps.some((a) => remoteAppId(c.id, a) === appName), ); const currentRuntime = - currentConn && currentConn.runtimeId + currentConn && currentConn.runtimeId && currentConn.region ? { runtimeId: currentConn.runtimeId, name: currentConn.name, - region: currentConn.region ?? "cn-beijing", + region: currentConn.region, } : undefined; const connectedRuntimeId = @@ -2672,8 +2753,14 @@ export default function App() { // Selecting an agent starts a fresh chat; any // background stream keeps persisting to its own (old) session. - const selectAgent = (id: string) => { + const selectAgent = async (id: string) => { setConnections(loadConnections()); + let capabilities = newChatCapabilitiesCacheRef.current.get(id); + if (!capabilities) { + capabilities = await probeNewChatCapabilities(id); + newChatCapabilitiesCacheRef.current.set(id, capabilities); + } + setNewChatCapabilities(capabilities); if (id === appName) setAgentInfoRefreshKey((key) => key + 1); viewSidRef.current = ""; setSessionId(""); @@ -2681,13 +2768,14 @@ export default function App() { setAppName(id); }; - const openAgentCreateFromMyAgents = () => { + const openAgentCreateFromMyAgents = (region: string) => { if (!canCreateAgents) { setError("当前账号没有添加 Agent 的权限。"); return; } setMyAgents(false); setManageAgents(false); + setNewRuntimeRegion(region); setImportedDraft(null); setCreateView(null); setAddMenu(true); @@ -2705,6 +2793,9 @@ export default function App() { ); setConnections(loadConnections()); setAgentInfoRefreshKey((key) => key + 1); + const capabilities = await probeNewChatCapabilities(agentId); + newChatCapabilitiesCacheRef.current.set(agentId, capabilities); + setNewChatCapabilities(capabilities); setAgentDetailTarget(null); setMyAgents(false); setManageAgents(false); @@ -2745,14 +2836,21 @@ export default function App() { const talkToWorkspaceAgent = (id: string) => { setFeedbackCaseReturnAgentId(""); setFeedbackTargetEventId(""); - selectAgent(id); + if (agentDetailTarget) { + void connectMyAgent(agentDetailTarget); + return; + } + setFocusedDeploymentTaskId(""); + setFocusedWorkspaceAgentId(""); + setManageAgents(false); + void selectAgent(id); }; const selectWorkspaceAgentFromNavbar = (id: string) => { setFocusedDeploymentTaskId(""); setFocusedWorkspaceAgentId(id); setFocusedWorkspaceAgentSection("basic"); - selectAgent(id); + return selectAgent(id); }; const detailConnection = agentDetailTarget?.runtime @@ -2814,6 +2912,7 @@ export default function App() { setMyAgents(false); setCreateView(null); setImportedDraft(null); + setNewRuntimeRegion("cn-beijing"); setAddMenu(true); setError(""); }} @@ -2977,21 +3076,28 @@ export default function App() { onAddFiles={addFiles} onRemoveAttachment={removeDraftAttachment} newChatMode={sandboxSession ? "agent" : newChatMode} + newChatTask={sandboxSession ? null : newChatTask} newChatLayout={!sandboxSession && turns.length === 0 && skillJob === null} showModeSelector={false} - temporaryEnabled={newChatCapabilities.temporaryEnabled} - skillCreateEnabled={newChatCapabilities.skillCreateEnabled} + temporaryEnabled={newChatCapabilitiesReady && newChatCapabilities.temporaryEnabled} + skillCreateEnabled={newChatCapabilitiesReady && newChatCapabilities.skillCreateEnabled} + harnessEnabled={newChatCapabilitiesReady && newChatCapabilities.harnessEnabled} + builtinTools={ + newChatCapabilitiesReady ? newChatCapabilities.builtinTools : [] + } onModeChange={(mode) => { if ( (mode === "temporary" && !newChatCapabilities.temporaryEnabled) || (mode === "skill-create" && !newChatCapabilities.skillCreateEnabled) ) return; if (mode === "temporary") { + setNewChatTask(null); setNewChatMode(mode); openSandboxLaunch(); return; } setNewChatMode(mode); + if (mode !== "agent") setNewChatTask(null); setError(""); if (mode === "skill-create") { setInvocation(emptyInvocation()); @@ -3008,6 +3114,7 @@ export default function App() { } } }} + onTaskChange={setNewChatTask} /> ); @@ -3164,6 +3271,7 @@ export default function App() { setCreateView(null); setImportedDraft(null); setRuntimeUpdateTarget(null); + setNewRuntimeRegion("cn-beijing"); setEditingDraftId(""); editingDraftBaselineRef.current = null; setFocusedDeploymentTaskId(""); @@ -3179,6 +3287,10 @@ export default function App() { setError("仅支持更新已部署的云端智能体。"); return; } + if (!currentConn.region) { + setError("Runtime 缺少地域信息,无法更新。"); + return; + } setManageAgents(false); setImportedDraft(nextDraft); const nextDraftId = `runtime-${currentConn.runtimeId}`; @@ -3190,7 +3302,7 @@ export default function App() { setRuntimeUpdateTarget({ runtimeId: currentConn.runtimeId, name: currentConn.name, - region: currentConn.region ?? "cn-beijing", + region: currentConn.region, currentVersion: currentConn.currentVersion, }); setCreateView("custom"); @@ -3322,6 +3434,7 @@ export default function App() { features={features} onDeploymentTaskChange={updateDeploymentTask} deploymentTarget={runtimeUpdateTarget ?? undefined} + initialDeployRegion={newRuntimeRegion} onDraftChange={(draft, dirty) => { if (!editingDraftId) return; if (dirty) { @@ -3364,9 +3477,14 @@ export default function App() { onDeploymentTaskChange={updateDeploymentTask} onDeploymentStarted={openDeploymentDetail} onDeploymentComplete={finishDeployment} + initialDeployRegion={newRuntimeRegion} /> ) : turns.length === 0 && skillJob ? ( + ) : turns.length === 0 && !newChatCapabilitiesReady ? ( +
+ 正在检查 Agent 能力… +
) : turns.length === 0 ? (
@@ -3489,6 +3607,12 @@ export default function App() { onStreamFrame={isLast ? followConversationStreamFrame : undefined} onAction={onAction} onAuth={onAuth} + onArtifactDownload={(filename, version) => + downloadArtifact(appName, userId, sessionId, filename, version) + } + onArtifactPreview={(filename, version) => + previewArtifact(appName, userId, sessionId, filename, version) + } /> {/* Finalized turn that produced no visible answer (e.g. only thinking + an empty A2UI surface) — show a fallback note. */} diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index f7310438..c0f2f3b8 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -553,6 +553,7 @@ export async function submitMessageFeedback(args: { if (!ep.runtimeId) { throw new Error("只有连接到 AgentKit Runtime 的会话支持反馈回流"); } + if (!ep.region) throw new Error("Runtime 缺少地域信息,无法提交反馈"); const res = await apiFetch( "/web/evaluation/feedback", { @@ -560,7 +561,7 @@ export async function submitMessageFeedback(args: { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ runtimeId: ep.runtimeId, - region: ep.region ?? "cn-beijing", + region: ep.region, appName: app, userId: args.userId, sessionId: args.sessionId, @@ -588,13 +589,13 @@ 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 ?? "cn-beijing", + region: args.region, appName: args.appName, page_size: String(args.pageSize ?? 100), }); @@ -607,7 +608,7 @@ export async function getAgentFeedbackCases(args: { export async function deleteAgentFeedbackCases(args: { runtimeId: string; - region?: string; + region: string; appName: string; itemIds: string[]; }): Promise<{ deletedCount: number }> { @@ -618,7 +619,7 @@ export async function deleteAgentFeedbackCases(args: { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ runtimeId: args.runtimeId, - region: args.region ?? "cn-beijing", + region: args.region, appName: args.appName, itemIds: args.itemIds, }), @@ -646,6 +647,87 @@ export async function deleteSession( if (!res.ok && res.status !== 404) throw new Error(`delete session failed: ${res.status}`); } +function decodeArtifactData(value: string): Uint8Array { + const standard = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = standard.padEnd(Math.ceil(standard.length / 4) * 4, "="); + const binary = window.atob(padded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +export async function downloadArtifact( + appName: string, + userId: string, + sessionId: string, + filename: string, + version?: number, +): Promise { + const { blob, downloadName } = await fetchArtifactBlob( + appName, + userId, + sessionId, + filename, + version, + ); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = downloadName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +async function fetchArtifactBlob( + appName: string, + userId: string, + sessionId: string, + filename: string, + version?: number, +): Promise<{ blob: Blob; downloadName: string }> { + const { app, ep } = resolve(appName); + const params = version == null ? "" : `?version=${encodeURIComponent(version)}`; + const path = `/apps/${encodeURIComponent(app)}/users/${encodeURIComponent(userId)}/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(filename)}${params}`; + const res = await apiFetch(path, {}, ep, TRANSFER_REQUEST_TIMEOUT_MS); + if (!res.ok) throw new Error(await httpErrorMessage(res, "下载文件失败")); + const part = (await res.json()) as AdkPart; + const inline = part.inlineData ?? part.inline_data; + if (!inline?.data) throw new Error("文件内容不可用"); + const bytes = decodeArtifactData(inline.data); + const buffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const blob = new Blob([buffer], { + type: inline.mimeType ?? inline.mime_type ?? "application/octet-stream", + }); + return { + blob, + downloadName: inline.displayName ?? inline.display_name ?? filename, + }; +} + +export async function previewArtifact( + appName: string, + userId: string, + sessionId: string, + filename: string, + version?: number, +): Promise { + const { blob } = await fetchArtifactBlob( + appName, + userId, + sessionId, + filename, + version, + ); + return URL.createObjectURL(blob); +} + export interface MediaCapabilities { maxFileBytes: number; mimeTypes: string[]; @@ -1699,7 +1781,7 @@ export async function getRuntimes( * the runtime does not support it (non-200 / not an ADK server). */ export async function probeRuntimeApps( runtimeId: string, - region = "cn-beijing", + region: string, ): Promise { try { const res = await fetchRemoteApps("", "", { runtimeId, region }); @@ -1718,7 +1800,7 @@ export async function probeRuntimeApps( /** Delete a deployed runtime by id. */ export async function deleteRuntime( runtimeId: string, - region = "cn-beijing", + region: string, ): Promise { const res = await apiFetch("/web/delete-runtime", { method: "POST", @@ -1764,7 +1846,7 @@ export interface RuntimeDetail { /** Fetch a runtime's control-plane detail (config/status/envs). */ export async function getRuntimeDetail( runtimeId: string, - region = "cn-beijing", + region: string, ): Promise { const res = await apiFetch( `/web/runtime-detail?runtimeId=${encodeURIComponent(runtimeId)}®ion=${encodeURIComponent(region)}`, @@ -1852,6 +1934,21 @@ export async function createGeneratedAgentTestSession( return session.id; } +export async function getGeneratedAgentTestTrace( + runId: string, + sessionId: string, +): Promise { + const res = await apiFetch( + `/web/generated-agent-test-runs/${encodeURIComponent(runId)}/trace/session/${encodeURIComponent(sessionId)}`, + ); + if (!res.ok) { + throw new Error(await httpErrorMessage(res, "加载调试调用链路失败")); + } + const spans = await parseJsonResponse(res, "加载调试调用链路失败"); + if (!Array.isArray(spans)) throw new Error("加载调试调用链路失败:返回格式无效"); + return spans as TraceSpan[]; +} + export async function* runGeneratedAgentTestSSE({ runId, userId, diff --git a/frontend/src/adk/connections.ts b/frontend/src/adk/connections.ts index 1d9777e5..272e28eb 100644 --- a/frontend/src/adk/connections.ts +++ b/frontend/src/adk/connections.ts @@ -46,7 +46,8 @@ const STORAGE_KEY = "veadk_agentkit_connections"; export function loadConnections(): RemoteConnection[] { try { const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as RemoteConnection[]) : []; + const parsed = raw ? (JSON.parse(raw) as RemoteConnection[]) : []; + return parsed.filter((connection) => !connection.runtimeId || !!connection.region); } catch { return []; } @@ -77,11 +78,12 @@ function hostOf(base: string): string { export function registerConnections(conns: RemoteConnection[]): void { clearRemoteApps(); for (const c of conns) { + if (c.runtimeId && !c.region) continue; for (const app of c.apps) { registerRemoteApp( remoteAppId(c.id, app), c.runtimeId - ? { app, runtimeId: c.runtimeId, region: c.region } + ? { app, runtimeId: c.runtimeId, region: c.region! } : { app, base: c.base, apiKey: c.apiKey }, ); } diff --git a/frontend/src/adk/runSseError.ts b/frontend/src/adk/runSseError.ts index c32eb4e1..90b0c633 100644 --- a/frontend/src/adk/runSseError.ts +++ b/frontend/src/adk/runSseError.ts @@ -1,4 +1,6 @@ const SESSION_NOT_FOUND_PATTERN = /\brun_sse\s*failed\s*:\s*404\b/i; +const TOOL_ARGUMENT_JSON_PATTERN = + /Expecting (?:'[^']+'|\w+)(?: delimiter)?: line \d+ column \d+ \(char \d+\)/i; const PERSISTENT_MEMORY_HINT = "提示:该 404 可能是多实例部署使用 in-memory 或 SQLite 短期记忆导致的:" + @@ -7,6 +9,9 @@ const PERSISTENT_MEMORY_HINT = /** Preserve a run error and append guidance for the common cross-instance 404. */ export function formatRunSseError(error: unknown): string { const message = String(error); + if (TOOL_ARGUMENT_JSON_PATTERN.test(message)) { + return "模型生成的工具参数格式不完整,请重新发送一次。"; + } if (!SESSION_NOT_FOUND_PATTERN.test(message) || message.includes(PERSISTENT_MEMORY_HINT)) { return message; } diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts index e09ef63c..49604c8a 100644 --- a/frontend/src/blocks.ts +++ b/frontend/src/blocks.ts @@ -53,6 +53,10 @@ export type Block = | { kind: "agent-transfer"; agentName: string; done: boolean } | { kind: "a2ui"; messages: A2uiMessage[] } | { kind: "attachment"; files: AttachmentView[] } + | { + kind: "artifact"; + files: { filename: string; version: number }[]; + } | { kind: "invocation"; value: FrontendInvocation } | { kind: "auth"; @@ -217,6 +221,20 @@ function appendAttachments(blocks: Block[], files: AttachmentView[]) { else blocks.push({ kind: "attachment", files }); } +function appendArtifacts(blocks: Block[], files: { filename: string; version: number }[]) { + if (!files.length) return; + const last = blocks[blocks.length - 1]; + if (last?.kind === "artifact") { + for (const file of files) { + if (!last.files.some((item) => + item.filename === file.filename && item.version === file.version + )) last.files.push(file); + } + return; + } + blocks.push({ kind: "artifact", files }); +} + function appendText(blocks: Block[], kind: "thinking" | "text", text: string) { const last = blocks[blocks.length - 1]; if (last && last.kind === kind) last.text += text; @@ -324,6 +342,13 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { } } } + const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta; + if (artifactDelta) { + appendArtifacts( + blocks, + Object.entries(artifactDelta).map(([filename, version]) => ({ filename, version })), + ); + } closeThinking(blocks); // a consolidated thinking segment is complete liveStart = blocks.length; return { blocks, liveStart }; diff --git a/frontend/src/create/AgentBuildCanvas.css b/frontend/src/create/AgentBuildCanvas.css index 9d424016..6c73d048 100644 --- a/frontend/src/create/AgentBuildCanvas.css +++ b/frontend/src/create/AgentBuildCanvas.css @@ -140,6 +140,10 @@ border-bottom: 1px solid hsl(var(--border) / 0.75); } +.abc-group.is-compact-empty .abc-group-head { + border-bottom: 0; +} + .abc-group-head > span:first-child { width: 100%; min-width: 0; diff --git a/frontend/src/create/AgentBuildCanvas.tsx b/frontend/src/create/AgentBuildCanvas.tsx index 4dc7dc28..96349e96 100644 --- a/frontend/src/create/AgentBuildCanvas.tsx +++ b/frontend/src/create/AgentBuildCanvas.tsx @@ -88,6 +88,7 @@ type CanvasNodeData = { containedIn?: AgentType; layoutWidth?: number; layoutHeight?: number; + compactEmptyGroup?: boolean; }; type CanvasNode = Node; @@ -123,13 +124,17 @@ function measureAgent( agent: AgentDraft, path: NodePath = [], direction: CanvasDirection = "horizontal", + compactEmptyGroups = false, ): { width: number; height: number } { const type = agent.agentType ?? "llm"; if (!rendersAsGroup(agent, path)) { return { width: NODE_WIDTH, height: NODE_HEIGHT }; } + if (compactEmptyGroups && agent.subAgents.length === 0) { + return { width: GROUP_MIN_WIDTH, height: GROUP_HEADER_HEIGHT }; + } const sizes = agent.subAgents.map((child, index) => - measureAgent(child, [...path, index], direction), + measureAgent(child, [...path, index], direction, compactEmptyGroups), ); const widestChild = sizes.length ? Math.max(...sizes.map((size) => size.width)) @@ -262,6 +267,7 @@ function makeEdge( function buildCanvasGraph( root: AgentDraft, direction: CanvasDirection, + compactEmptyGroups = false, ): { nodes: CanvasNode[]; edges: CanvasEdge[]; @@ -331,7 +337,7 @@ function buildCanvasGraph( ): string { const type = agent.agentType ?? "sequential"; const id = pathId(path); - const size = measureAgent(agent, path, direction); + const size = measureAgent(agent, path, direction, compactEmptyGroups); nodes.push({ id, type: "group", @@ -352,11 +358,12 @@ function buildCanvasGraph( containedIn, layoutWidth: size.width, layoutHeight: size.height, + compactEmptyGroup: compactEmptyGroups && agent.subAgents.length === 0, }, }); const childSizes = agent.subAgents.map((child, index) => - measureAgent(child, [...path, index], direction), + measureAgent(child, [...path, index], direction, compactEmptyGroups), ); const flowPadding = childSizes.length && type !== "parallel" ? GROUP_BOUNDARY_PADDING @@ -676,7 +683,11 @@ function AgentGroupNode({ data, selected }: NodeProps) { ? "添加循环步骤" : "添加下一个步骤"; return ( -
+
@@ -1000,18 +1011,23 @@ function AgentBuildCanvasInner({ interactivePreview = false, direction = "horizontal", }: AgentBuildCanvasProps) { - const initialGraph = useMemo(() => buildCanvasGraph(draft, direction), []); + const initialGraph = useMemo( + () => buildCanvasGraph(draft, direction, readOnly), + [], + ); const [nodes, setNodes, onNodesChange] = useNodesState( initialGraph.nodes, ); const [edges, setEdges, onEdgesChange] = useEdgesState(initialGraph.edges); const nodesInitialized = useNodesInitialized(); - const lastStructure = useRef(`${direction}:${structureKey(draft)}`); + const lastStructure = useRef( + `${direction}:${readOnly ? "readonly" : "editable"}:${structureKey(draft)}`, + ); const canvasRef = useRef(null); const { fitView } = useReactFlow(); const currentGraph = useMemo( - () => buildCanvasGraph(draft, direction), - [direction, draft], + () => buildCanvasGraph(draft, direction, readOnly), + [direction, draft, readOnly], ); const [compactCanvas, setCompactCanvas] = useState(() => window.matchMedia("(max-width: 860px)").matches, @@ -1040,7 +1056,9 @@ function AgentBuildCanvasInner({ }, []); useEffect(() => { - const nextStructure = `${direction}:${structureKey(draft)}`; + const nextStructure = `${direction}:${ + readOnly ? "readonly" : "editable" + }:${structureKey(draft)}`; const structureChanged = nextStructure !== lastStructure.current; lastStructure.current = nextStructure; setEdges(currentGraph.edges); diff --git a/frontend/src/create/CodePackageCreate.tsx b/frontend/src/create/CodePackageCreate.tsx index d8e4b082..e9670f75 100644 --- a/frontend/src/create/CodePackageCreate.tsx +++ b/frontend/src/create/CodePackageCreate.tsx @@ -27,6 +27,7 @@ interface CodePackageCreateProps { onDeploymentTaskChange?: (task: DeploymentTaskUpdate) => void; onDeploymentStarted?: (task: DeploymentTaskUpdate) => void; onDeploymentComplete?: (result: DeployResult) => void | Promise; + initialDeployRegion?: string; } function packageProjectName(fileName: string): string { @@ -87,6 +88,7 @@ export function CodePackageCreate({ onDeploymentTaskChange, onDeploymentStarted, onDeploymentComplete, + initialDeployRegion = "cn-beijing", }: CodePackageCreateProps) { const inputRef = useRef(null); const loadRunRef = useRef(0); @@ -96,7 +98,7 @@ export function CodePackageCreate({ const [reading, setReading] = useState(false); const [dragging, setDragging] = useState(false); const [error, setError] = useState(""); - const [deployRegion, setDeployRegion] = useState("cn-beijing"); + const [deployRegion, setDeployRegion] = useState(initialDeployRegion); const [network, setNetwork] = useState(); useEffect( diff --git a/frontend/src/create/CustomCreate.css b/frontend/src/create/CustomCreate.css index bee6b4aa..51f084eb 100644 --- a/frontend/src/create/CustomCreate.css +++ b/frontend/src/create/CustomCreate.css @@ -1666,6 +1666,32 @@ gap: 8px; padding: 0 12px 12px; } +.cw-ab-trace { + min-height: 32px; + margin-right: auto; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + font: inherit; + font-size: 11.5px; + font-weight: 500; + transition: background-color 160ms ease, color 160ms ease; +} +.cw-ab-trace:hover:not(:disabled) { + background: hsl(var(--secondary) / 0.58); + color: hsl(var(--foreground)); +} +.cw-ab-trace:focus-visible { + outline: 2px solid hsl(var(--ring) / 0.55); + outline-offset: 2px; +} +.cw-ab-trace:disabled { + color: hsl(var(--muted-foreground) / 0.48); + cursor: not-allowed; +} .cw-ab-footer-start { min-width: 0; background: hsl(var(--secondary) / 0.58); diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx index c888eeac..f9d283ee 100644 --- a/frontend/src/create/CustomCreate.tsx +++ b/frontend/src/create/CustomCreate.tsx @@ -89,6 +89,7 @@ import { } from "../ui/ProjectPreview"; import { Blocks, ThinkingPlaceholder } from "../ui/Blocks"; import { DeploymentErrorMessage } from "../ui/DeploymentErrorMessage"; +import { TraceDrawer } from "../ui/TraceDrawer"; import { isImeCompositionEvent } from "../ui/composerKeyboard"; import { createGeneratedAgentTestRun, @@ -1745,6 +1746,12 @@ interface DebugVariant { error: string | null; } +interface DebugTraceTarget { + runId: string; + sessionId: string; + variantName: string; +} + function codegenDraft(draft: AgentDraft): AgentDraft { return { ...draft, @@ -1830,6 +1837,7 @@ function DebugComparisonWorkspace({ onToggleConfig, onCompleteConfig, onConfigChange, + onOpenTrace, }: { enabled: boolean; disabledReason: string; @@ -1849,6 +1857,7 @@ function DebugComparisonWorkspace({ field: "modelName" | "description" | "instruction", value: string, ) => void; + onOpenTrace: (id: string) => void; }) { const runningVariants = variants.filter((variant) => { if (variant.phase !== "ready") return false; @@ -1888,6 +1897,10 @@ function DebugComparisonWorkspace({ const starting = variant.phase === "starting"; const ready = variant.phase === "ready" && !stale; const busy = starting || variant.phase === "sending"; + const traceAvailable = + ready && + variant.phase !== "sending" && + variant.messages.some((message) => message.role === "assistant"); const startDisabled = busy || variant.configOpen || configurationUnavailable; const disabledReason = !modelName @@ -2007,6 +2020,19 @@ function DebugComparisonWorkspace({
+
@@ -4129,6 +4173,14 @@ export function CustomCreate({ )} + {debugTraceTarget && ( + setDebugTraceTarget(null)} + /> + )} {discardConfirmOpen && (
setDiscardConfirmOpen(false)}>
span:last-child { + flex: 0 0 auto; + white-space: nowrap; +} +.new-chat-task-chip:hover, +.new-chat-task-chip:focus-visible { + background: hsl(260 36% 96%); + outline: none; +} +.new-chat-task-chip:active { transform: scale(0.97); } +.new-chat-task-chip:disabled { cursor: default; opacity: 0.5; } +.new-chat-task-chip__icon { + position: relative; + display: grid; + place-items: center; + width: 20px; + height: 20px; + flex: 0 0 20px; + border-radius: 50%; +} +.new-chat-task-chip__task-icon, +.new-chat-task-chip__remove-icon { + position: absolute; + width: 18px; + height: 18px; + transition: opacity 120ms ease, transform 150ms ease; +} +.new-chat-task-chip__remove-icon { + width: 12px; + height: 12px; + padding: 3px; + border-radius: 50%; + background: hsl(262 38% 58%); + color: white; + opacity: 0; + transform: scale(0.72); + box-sizing: content-box; +} +.new-chat-task-chip:hover .new-chat-task-chip__task-icon, +.new-chat-task-chip:focus-visible .new-chat-task-chip__task-icon { + opacity: 0; + transform: scale(0.72); +} +.new-chat-task-chip:hover .new-chat-task-chip__remove-icon, +.new-chat-task-chip:focus-visible .new-chat-task-chip__remove-icon { + opacity: 1; + transform: scale(1); +} .composer--new-chat .comp-send { position: absolute; right: 10px; @@ -2086,6 +2303,64 @@ body { width: 20px; height: 20px; } +.task-shortcuts { + position: absolute; + top: calc(100% + 18px); + left: 0; + z-index: 1; + display: flex; + justify-content: center; + flex-wrap: wrap; + width: 100%; + gap: 10px; +} +.task-shortcut { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + gap: 8px; + min-width: 92px; + height: 40px; + padding: 0 18px; + border: 1px solid hsl(var(--border) / 0.72); + border-radius: 999px; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + font: inherit; + font-size: 13px; + line-height: 1; + white-space: nowrap; + cursor: pointer; + opacity: 0; + transform: translateY(6px); + animation: task-shortcut-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) forwards; + transition: border-color 140ms ease, background 140ms ease, color 140ms ease, transform 140ms ease; +} +.task-shortcut > span { white-space: nowrap; } +.task-shortcut:nth-child(2) { animation-delay: 45ms; } +.task-shortcut:nth-child(3) { animation-delay: 90ms; } +.task-shortcut:nth-child(4) { animation-delay: 135ms; } +.task-shortcut:hover { + border-color: hsl(262 30% 57% / 0.34); + background: hsl(260 34% 97%); + color: hsl(262 34% 50%); + transform: translateY(-1px); +} +.task-shortcut:focus-visible { + outline: 2px solid hsl(262 30% 57% / 0.34); + outline-offset: 2px; +} +.task-shortcut:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.task-shortcut > svg { + flex: 0 0 auto; + width: 18px; + height: 18px; + stroke: currentColor; +} .prompt-suggestions { position: absolute; top: calc(100% + 18px); @@ -2118,6 +2393,7 @@ body { } .prompt-suggestion:nth-child(2) { animation-delay: 65ms; } .prompt-suggestion:nth-child(3) { animation-delay: 130ms; } +.prompt-suggestion:nth-child(4) { animation-delay: 195ms; } .prompt-suggestion:hover { background: hsl(var(--foreground) / 0.025); color: hsl(var(--foreground)); @@ -2131,9 +2407,9 @@ body { opacity: 0.5; } .prompt-suggestion > svg { - flex: 0 0 auto; width: 18px; height: 18px; + flex: 0 0 auto; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; @@ -2141,34 +2417,48 @@ body { transform-origin: center; transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1); } -.prompt-suggestion:nth-child(1):hover > svg { - transform: rotate(-8deg) scale(1.06); -} -.prompt-suggestion:nth-child(2):hover > svg { - transform: rotate(6deg) scale(1.07); -} -.prompt-suggestion:nth-child(3):hover > svg { - transform: rotate(-5deg) scale(1.06); -} +.prompt-suggestion > span { + display: block; + min-width: 0; + max-height: 1.5em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + transition: max-height 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.prompt-suggestion:hover > span, +.prompt-suggestion:focus-visible > span { + max-height: 4.5em; + white-space: normal; + text-overflow: clip; +} +.prompt-suggestion:nth-child(1):hover > svg { transform: rotate(-8deg) scale(1.06); } +.prompt-suggestion:nth-child(2):hover > svg { transform: rotate(6deg) scale(1.07); } +.prompt-suggestion:nth-child(3):hover > svg { transform: rotate(-5deg) scale(1.06); } +.prompt-suggestion:nth-child(4):hover > svg { transform: rotate(5deg) scale(1.06); } @keyframes prompt-suggestion-enter { + to { opacity: 1; transform: translateY(0); } +} +@keyframes task-shortcut-enter { to { opacity: 1; transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { - .prompt-suggestion { + .task-shortcut, + .prompt-suggestion, + .new-chat-task-chip, + .new-chat-task-chip__task-icon, + .new-chat-task-chip__remove-icon { opacity: 1; transform: none; animation: none; transition: none; } - .prompt-suggestion > svg { - transition: none; - } - .prompt-suggestion:hover > svg { - transform: none; - } + .prompt-suggestion > svg { transition: none; } + .prompt-suggestion > span { transition: none; } + .prompt-suggestion:hover > svg { transform: none; } } .composer-meta { display: flex; diff --git a/frontend/src/ui/AgentSelector.tsx b/frontend/src/ui/AgentSelector.tsx index 27336306..2987bdba 100644 --- a/frontend/src/ui/AgentSelector.tsx +++ b/frontend/src/ui/AgentSelector.tsx @@ -57,7 +57,7 @@ export interface AgentSelectorProps { /** Maximum runtime scope granted by the server. */ runtimeScope: RuntimeScope; /** Called with the picker id once an agent is chosen. */ - onSelect: (id: string) => void; + onSelect: (id: string) => void | Promise; } const PAGE_SIZE = 15; @@ -303,8 +303,8 @@ export function AgentSelector({ function connect(rt: CloudRuntime) { setConnecting(rt.runtimeId); connectRuntime(rt.runtimeId, rt.name, rt.region) - .then((agentId) => { - onSelect(agentId); + .then(async (agentId) => { + await onSelect(agentId); onClose(); }) .catch((error) => { diff --git a/frontend/src/ui/AgentWorkspace.css b/frontend/src/ui/AgentWorkspace.css index c4d92ddb..2d437ef6 100644 --- a/frontend/src/ui/AgentWorkspace.css +++ b/frontend/src/ui/AgentWorkspace.css @@ -137,6 +137,10 @@ grid-template-columns: minmax(0, 1fr); } +.aw-root.is-detail-only .aw-agent-head { + padding-top: 24px; +} + .aw-sidebar { min-width: 0; min-height: 0; diff --git a/frontend/src/ui/AgentWorkspace.tsx b/frontend/src/ui/AgentWorkspace.tsx index 92d90196..e39b5aca 100644 --- a/frontend/src/ui/AgentWorkspace.tsx +++ b/frontend/src/ui/AgentWorkspace.tsx @@ -852,11 +852,13 @@ export function AgentWorkspace({ useEffect(() => { let cancelled = false; setDetailAgentInfo(null); - setDetailAgentInfoResolved(!detailOnly || !selectedAgent?.runtimeId); - if (!detailOnly || !selectedAgent?.runtimeId) return; + setDetailAgentInfoResolved( + !detailOnly || !selectedAgent?.runtimeId || !selectedAgent.region, + ); + if (!detailOnly || !selectedAgent?.runtimeId || !selectedAgent.region) return; void getRuntimeAgentInfo( selectedAgent.runtimeId, - selectedAgent.region ?? "cn-beijing", + selectedAgent.region, selectedAgent.runtimeApp, ) .then((info) => { @@ -882,10 +884,10 @@ export function AgentWorkspace({ useEffect(() => { let cancelled = false; setRuntimeDetail(null); - if (!selectedAgent?.runtimeId) return; + if (!selectedAgent?.runtimeId || !selectedAgent.region) return; void getRuntimeDetail( selectedAgent.runtimeId, - selectedAgent.region ?? "cn-beijing", + selectedAgent.region, ) .then((detail) => { if (!cancelled) setRuntimeDetail(detail); @@ -907,14 +909,14 @@ export function AgentWorkspace({ setFeedbackCases([]); setFeedbackSets([]); setFeedbackCasesError(""); - if (section !== "evaluations" || !selectedAgent?.runtimeId) { + if (section !== "evaluations" || !selectedAgent?.runtimeId || !selectedAgent.region) { setFeedbackCasesLoading(false); return; } setFeedbackCasesLoading(true); void getAgentFeedbackCases({ runtimeId: selectedAgent.runtimeId, - region: selectedAgent.region ?? "cn-beijing", + region: selectedAgent.region, appName: selectedAgent.app, pageSize: 100, }) @@ -1067,7 +1069,12 @@ export function AgentWorkspace({ }; const deleteCases = async (items: AgentCase[]) => { - if (!selectedAgent?.runtimeId || deletingCases || items.length === 0) return; + if ( + !selectedAgent?.runtimeId || + !selectedAgent.region || + deletingCases || + items.length === 0 + ) return; const confirmText = items.length === 1 ? "确定删除这条反馈案例?原始聊天记录不会被删除。" : `确定删除选中的 ${items.length} 条反馈案例?原始聊天记录不会被删除。`; @@ -1079,7 +1086,7 @@ export function AgentWorkspace({ try { await deleteAgentFeedbackCases({ runtimeId: selectedAgent.runtimeId, - region: selectedAgent.region ?? "cn-beijing", + region: selectedAgent.region, appName: selectedAgent.app, itemIds: ids, }); @@ -1672,7 +1679,7 @@ export function AgentWorkspace({

{draft.description || (loadingAgentInfo || (detailOnly && !detailAgentInfoResolved) ? "正在读取智能体信息…" : "暂无描述")}

- {(selectedAgent?.canDelete || selectedDraft || selectedAgentUpdateDraft) && ( + {(selectedDraft || selectedAgentUpdateDraft) && (
{(selectedDraft || selectedAgentUpdateDraft) && ( )} - {selectedAgent?.canDelete && ( - - )}
)} diff --git a/frontend/src/ui/Blocks.tsx b/frontend/src/ui/Blocks.tsx index 4136489b..260f2606 100644 --- a/frontend/src/ui/Blocks.tsx +++ b/frontend/src/ui/Blocks.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { ChevronRight, Loader2, ShieldCheck } from "lucide-react"; +import { ChevronRight, Download, Eye, FileText, Loader2, ShieldCheck, X } from "lucide-react"; import { motion } from "motion/react"; import type { Block } from "../blocks"; import { buildSurfaces, SurfaceView } from "../a2ui/Surface"; @@ -299,6 +299,110 @@ function ToolBlock({ } type AuthBlock = Extract; +type ArtifactBlock = Extract; + +function ArtifactCard({ + block, + onDownload, + onPreview, +}: { + block: ArtifactBlock; + onDownload?: (filename: string, version: number) => Promise; + onPreview?: (filename: string, version: number) => Promise; +}) { + const [pending, setPending] = useState(""); + const [error, setError] = useState(""); + const [preview, setPreview] = useState<{ name: string; url: string } | null>(null); + useEffect(() => () => { + if (preview) URL.revokeObjectURL(preview.url); + }, [preview]); + + const closePreview = () => setPreview(null); + const download = async (filename: string, version: number) => { + if (!onDownload) return; + setPending(`download:${filename}`); + setError(""); + try { + await onDownload(filename, version); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setPending(""); + } + }; + const openPreview = async (filename: string, version: number, name: string) => { + if (!onPreview) return; + setPending(`preview:${name}`); + setError(""); + try { + const url = await onPreview(filename, version); + setPreview({ name, url }); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setPending(""); + } + }; + const files = block.files.filter((file) => !file.filename.endsWith(".preview.webp")); + return ( +
+ {files.map((file) => { + const previewName = `${file.filename.replace(/\.pptx$/i, "")}.preview.webp`; + const previewFile = block.files.find((item) => item.filename === previewName); + return ( +
+ + + {file.filename} + PowerPoint 演示文稿 + + + {previewFile && ( + + )} + + +
+ )})} + {error &&
{error}
} + {preview && ( +
+ +
+
+ {`${preview.name} +
+
+ + )} + + ); +} /** OAuth authorization card for an `adk_request_credential` request (MCP/tool * OAuth). Clicking runs the app's onAuth handler (popup + callback + resume). */ @@ -404,6 +508,8 @@ export interface BlocksProps { onAction: (action: A2uiAction | undefined, node: A2uiComponent) => void; /** Handle an MCP/tool OAuth request (opens auth URL, resumes the run). */ onAuth?: (block: AuthBlock) => Promise; + onArtifactDownload?: (filename: string, version: number) => Promise; + onArtifactPreview?: (filename: string, version: number) => Promise; } export function Blocks({ @@ -413,6 +519,8 @@ export function Blocks({ onStreamFrame, onAction, onAuth, + onArtifactDownload, + onArtifactPreview, }: BlocksProps) { return ( <> @@ -441,6 +549,8 @@ export function Blocks({ } case "attachment": return ; + case "artifact": + return ; case "invocation": return ; case "tool": diff --git a/frontend/src/ui/Composer.tsx b/frontend/src/ui/Composer.tsx index 63224f5c..b345b90a 100644 --- a/frontend/src/ui/Composer.tsx +++ b/frontend/src/ui/Composer.tsx @@ -1,4 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { ComponentType, SVGProps } from "react"; import { ArrowUp, AtSign, @@ -9,8 +10,10 @@ import { FileVideo2, ImageIcon, Loader2, + MonitorPlay, Plus, Sparkles, + X, } from "lucide-react"; import { motion } from "motion/react"; import type { @@ -23,8 +26,29 @@ import { InvocationChips } from "./InvocationChips"; import { MediaGroup } from "./Media"; import { isImeCompositionEvent } from "./composerKeyboard"; import { NewChatModeSelector } from "./new-chat-modes/NewChatModeSelector"; -import type { NewChatMode } from "./new-chat-modes/types"; +import type { NewChatMode, NewChatTask } from "./new-chat-modes/types"; +import { NEW_CHAT_TASK_TOOLS } from "./new-chat-modes/taskTools"; import { SKILL_MODELS } from "./skill-create/types"; +import { VideoGenerateIcon } from "./builtin-tools/icons"; + +function SkillCreateIcon(props: SVGProps) { + return ( + + ); +} interface CompletionTrigger { kind: "skill" | "agent"; @@ -70,19 +94,51 @@ function RewritePromptIcon() { } const STARTER_PROMPTS = [ + { icon: AnalyzePromptIcon, text: "帮我分析一个问题,并给出清晰的解决思路" }, + { icon: PlanPromptIcon, text: "根据我的目标,制定一份可执行的行动计划" }, + { icon: RewritePromptIcon, text: "帮我整理并润色一段内容,让表达更清晰" }, +] as const; + +const TASK_SHORTCUTS = [ { - icon: AnalyzePromptIcon, - text: "帮我分析一个问题,并给出清晰的解决思路", + value: "ppt", + label: "PPT", + icon: MonitorPlay, + prompts: [ + "复盘【季度】经营表现,提炼指标差距、原因与行动建议", + "汇报【项目名称】进展:里程碑、风险、预算和资源诉求", + "为【客户行业】输出解决方案:痛点、架构、实施路径与收益", + "分析【行业主题】趋势,给出竞争格局、机会与战略建议", + ], }, { - icon: PlanPromptIcon, - text: "根据我的目标,制定一份可执行的行动计划", + value: "image", + label: "图片生成", + icon: ImageIcon, + prompts: [ + "为【品牌或产品】设计【高级科技】风格的发布会主视觉", + "生成【产品名称】电商海报,突出【核心卖点】与品牌色", + "呈现【产品或空间】在【使用场景】中的写实概念效果图", + "围绕【传播主题】制作简洁专业的企业社媒配图", + ], }, { - icon: RewritePromptIcon, - text: "帮我整理并润色一段内容,让表达更清晰", + value: "video", + label: "视频生成", + icon: VideoGenerateIcon, + prompts: [ + "制作【品牌名称】30 秒宣传片,突出【品牌价值】", + "为【产品名称】制作 45 秒发布视频:痛点、功能、场景与行动号召", + "制作【培训主题】企业培训视频,讲清【关键操作或规范】", + "生成【活动名称】20 秒预热视频,包含亮点、时间地点和报名信息", + ], }, -] as const; +] as const satisfies ReadonlyArray<{ + value: NewChatTask; + label: string; + icon: ComponentType>; + prompts: readonly string[]; +}>; export interface ComposerProps { sessionId: string; @@ -105,11 +161,15 @@ export interface ComposerProps { onAddFiles: (files: FileList | File[]) => void; onRemoveAttachment: (id: string) => void; newChatMode?: NewChatMode; + newChatTask?: NewChatTask | null; newChatLayout?: boolean; showModeSelector?: boolean; onModeChange?: (value: NewChatMode) => void; + onTaskChange?: (value: NewChatTask | null) => void; temporaryEnabled?: boolean; skillCreateEnabled?: boolean; + harnessEnabled?: boolean; + builtinTools?: readonly string[]; } export function Composer({ @@ -133,11 +193,15 @@ export function Composer({ onAddFiles, onRemoveAttachment, newChatMode = "agent", + newChatTask = null, newChatLayout = false, showModeSelector = false, onModeChange, + onTaskChange, temporaryEnabled, skillCreateEnabled, + harnessEnabled = false, + builtinTools = [], }: ComposerProps) { const ref = useRef(null); const imageInput = useRef(null); @@ -205,6 +269,48 @@ export function Composer({ }); } + function applyTaskShortcut(task: (typeof TASK_SHORTCUTS)[number]) { + onTaskChange?.(task.value); + setMenuOpen(false); + setTrigger(null); + requestAnimationFrame(() => { + ref.current?.focus(); + ref.current?.setSelectionRange(value.length, value.length); + }); + } + + function applyTaskPrompt(prompt: string) { + onChange(prompt); + setMenuOpen(false); + setTrigger(null); + requestAnimationFrame(() => { + ref.current?.focus(); + const placeholderStart = prompt.indexOf("【"); + const placeholderEnd = prompt.indexOf("】", placeholderStart + 1); + if (placeholderStart >= 0 && placeholderEnd > placeholderStart) { + ref.current?.setSelectionRange(placeholderStart + 1, placeholderEnd); + } else { + ref.current?.setSelectionRange(prompt.length, prompt.length); + } + }); + } + + function clearTask() { + onTaskChange?.(null); + onChange(""); + setMenuOpen(false); + setTrigger(null); + requestAnimationFrame(() => { + ref.current?.focus(); + ref.current?.setSelectionRange(0, 0); + }); + } + + const selectedTask = TASK_SHORTCUTS.find((task) => task.value === newChatTask); + const availableTaskShortcuts = TASK_SHORTCUTS.filter((task) => + NEW_CHAT_TASK_TOOLS[task.value].every((tool) => builtinTools.includes(tool)), + ); + function updateCompletion(nextValue: string, cursor: number) { const prefix = nextValue.slice(0, cursor); const match = /(^|\s)([/@])([^\s/@]*)$/.exec(prefix); @@ -266,7 +372,7 @@ export function Composer({ } return ( -
+
{!skillMode ? ( ) : null} + {newChatLayout && newChatMode === "agent" && selectedTask && onTaskChange ? ( + + ) : null} + + {newChatLayout && skillMode && onModeChange ? ( + + ) : null} +