diff --git a/deploy/sql/migrations/v2.5.0_0817_add_agent_is_a2a.sql b/deploy/sql/migrations/v2.5.0_0817_add_agent_is_a2a.sql index 8372becb03..6d9008d72f 100644 --- a/deploy/sql/migrations/v2.5.0_0817_add_agent_is_a2a.sql +++ b/deploy/sql/migrations/v2.5.0_0817_add_agent_is_a2a.sql @@ -6,17 +6,29 @@ COMMENT ON COLUMN nexent.ag_tenant_agent_t.is_a2a IS 'Whether the draft configuration publishes this agent as an A2A Server'; -- Preserve the A2A state of agents that have at least one historical A2A version. -UPDATE nexent.ag_tenant_agent_t AS agent -SET is_a2a = TRUE -WHERE agent.version_no = 0 - AND agent.delete_flag = 'N' - AND EXISTS ( - SELECT 1 - FROM nexent.ag_tenant_agent_version_t AS version - WHERE version.agent_id = agent.agent_id - AND version.tenant_id = agent.tenant_id - AND version.is_a2a IS TRUE - ); +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'nexent' + AND table_name = 'ag_tenant_agent_version_t' + AND column_name = 'is_a2a' + ) THEN + UPDATE nexent.ag_tenant_agent_t AS agent + SET is_a2a = TRUE + WHERE agent.version_no = 0 + AND agent.delete_flag = 'N' + AND EXISTS ( + SELECT 1 + FROM nexent.ag_tenant_agent_version_t AS version + WHERE version.agent_id = agent.agent_id + AND version.tenant_id = agent.tenant_id + AND version.is_a2a IS TRUE + ); + END IF; +END +$$; -- A2A publication state is now owned exclusively by ag_tenant_agent_t. ALTER TABLE nexent.ag_tenant_agent_version_t diff --git a/frontend/app/[locale]/agents/page.tsx b/frontend/app/[locale]/agents/page.tsx index 6a981f59d2..b025913174 100644 --- a/frontend/app/[locale]/agents/page.tsx +++ b/frontend/app/[locale]/agents/page.tsx @@ -277,67 +277,67 @@ function AgentSetupContent() {
- {isGenerationVisible && ( - setIsGenerationVisible(false)} - className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + setIsGenerationVisible(false)} + className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + > + + + } + > +
+ {isNl2AgentUnavailable ? ( +
- - - } - > -
- {isNl2AgentUnavailable ? ( -
+ {t( + "nl2agent.unavailable", + "Create or select an editable Agent first." + )} +
+ ) : null} + {completionSyncFailed ? ( +
+ {t( - "nl2agent.unavailable", - "Create or select an editable Agent first." + "nl2agent.completion.syncFailed", + "The Agent was generated, but the form could not be refreshed." )} -
- ) : null} - {completionSyncFailed ? ( -
+ -
- ) : null} - -
- - )} + {t("nl2agent.completion.retry", "Retry")} + +
+ ) : null} + +
+
- {isDebugVisible && ( - - {t("agent.debug.compareMode")} - -
- } - rightAction={ -
-
+ } + rightAction={ +
+ - -
- } - > -
- + setIsGenerationVisible(false); + setIsShowVersionManagePanel(false); + setIsDebugFullscreen(true); + }} + className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700" + > + {isDebugFullscreen ? ( + + ) : ( + + )} + +
- - )} + } + > +
+ +
+ {isShowVersionManagePanel && ( { + pendingThreadOperationId = threadId; +}; + const MAX_TITLE_WAIT_MS = 5_000; const TITLE_POLL_INTERVAL_MS = 50; @@ -1282,13 +1288,21 @@ const waitForServerConversationId = async ( // reliable than the active-thread registry while the sidebar is switching. if (isValidConversationId(fallbackRemoteId)) return fallbackRemoteId; - // Fast path: the ref is already populated for a new thread after its first - // agent run has returned the server-side conversation ID. + // New threads use an empty remoteId until they are reloaded from the + // backend. The sidebar captures the local ID before calling rename/delete, + // because assistant-ui may switch the active thread as part of that action. const readNow = (): string | undefined => { + if (pendingThreadOperationId) { + const fromPendingThread = idsRef.current.get(pendingThreadOperationId); + if (isValidConversationId(fromPendingThread)) return fromPendingThread; + } + const activeThreadId = getActiveThreadId(); if (!activeThreadId) return undefined; - const fromRef = idsRef.current.get(activeThreadId); - return isValidConversationId(fromRef) ? fromRef : undefined; + const fromActiveThread = idsRef.current.get(activeThreadId); + return isValidConversationId(fromActiveThread) + ? fromActiveThread + : undefined; }; const immediate = readNow(); @@ -1348,19 +1362,6 @@ export const conversationThreadListAdapter: RemoteThreadListAdapter = { // doing so would create a second, empty conversation that the agent // run never reuses (see commit history for details). // - // We return an empty-string `remoteId` rather than `undefined` because - // the assistant-ui `RemoteThreadListAdapter["initialize"]` contract - // requires a string. The empty string is a safe placeholder: the page - // resolves `activeConversationId` with priority - // `serverConversationIdsRef → remoteId → activeThreadId`, so as soon as - // the adapter captures the server id from the response header the page - // starts using the real id instead of this placeholder. - // - // `generateTitle` follows the same priority chain: it consults the page's - // `serverConversationIdsRef` via `waitForServerConversationId` before - // falling back to the raw `remoteId`, so a brand-new thread no longer - // triggers a `conversation_id: 0` request (which would silently fail on - // the backend's `WHERE conversation_id = 0` filter). return { remoteId: "", externalId: "", @@ -1417,8 +1418,7 @@ export const conversationThreadListAdapter: RemoteThreadListAdapter = { return toRemoteThreadMetadata({ conversation_id: Number(detail.conversation_id), - conversation_title: - detail.conversation_title ?? "Untitled conversation", + conversation_title: detail.conversation_title ?? "Untitled conversation", agent_id: detail.agent_id, create_time: detail.create_time, update_time: detail.create_time, diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx index 1d70f7f2cf..df156246cc 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread-list.tsx @@ -41,6 +41,7 @@ import { shouldContinueConversationPageLoading, shouldLoadNextConversationPage, } from "@/lib/conversationLoadPolicy"; +import { setPendingThreadOperationId } from "../adapter/conversation-thread-list-adapter"; import { calculateConversationViewport, getConversationViewportGroupCounts, @@ -337,7 +338,6 @@ const ThreadListItems: FC = ({ const groups = useThreadListGroups(); - const GroupedThreadListItem = useMemo( () => () => ( { if (!date || date.getTime() >= startOfToday) return "chat.threadList.today"; if (date.getTime() >= startOfToday - 7 * DAY_IN_MS) { @@ -416,10 +416,12 @@ const useThreadListGroups = (): ThreadListGroup[] | null => { return useMemo(() => { const itemsById = new Map( - (threadItems as ReadonlyArray<{ - id: string; - custom?: { lastMessageAt?: string }; - }>).map((item) => [item.id, item]), + ( + threadItems as ReadonlyArray<{ + id: string; + custom?: { lastMessageAt?: string }; + }> + ).map((item) => [item.id, item]) ); const dates: (Date | undefined)[] = threadIds.map((id) => { const raw = itemsById.get(id)?.custom?.lastMessageAt; @@ -431,7 +433,7 @@ const useThreadListGroups = (): ThreadListGroup[] | null => { const startOfToday = new Date( now.getFullYear(), now.getMonth(), - now.getDate(), + now.getDate() ).getTime(); const time = (index: number) => @@ -512,18 +514,25 @@ const ThreadListItemContent: FC = ({ const [isEditing, setIsEditing] = useState(false); const threadListItem = aui.threadListItem; const thread = threadListItem.getState(); - const title = generatedTitles?.get(thread.id) ?? thread.title ?? t("chat.thread.newChat"); - - const handleRename = useCallback(async (newTitle: string) => { - try { - await threadListItem.rename(newTitle); - log.log(`[ThreadList] Renamed thread to "${newTitle}"`); - setIsEditing(false); - } catch (error) { - log.error("[ThreadList] Failed to rename thread:", error); - message.error(t("chat.threadList.renameFailed")); - } - }, [threadListItem, t]); + const title = + generatedTitles?.get(thread.id) ?? thread.title ?? t("chat.thread.newChat"); + + const handleRename = useCallback( + async (newTitle: string) => { + setPendingThreadOperationId(thread.id); + try { + await threadListItem.rename(newTitle); + log.log(`[ThreadList] Renamed thread to "${newTitle}"`); + setIsEditing(false); + } catch (error) { + log.error("[ThreadList] Failed to rename thread:", error); + message.error(t("chat.threadList.renameFailed")); + } finally { + setPendingThreadOperationId(undefined); + } + }, + [thread.id, threadListItem, t] + ); const handleRenameClick = useCallback(() => { setIsEditing(true); @@ -538,6 +547,7 @@ const ThreadListItemContent: FC = ({ title: t("chat.threadList.delete"), content: t("chat.threadList.confirmDeletionDescription"), onOk: async () => { + setPendingThreadOperationId(thread.id); try { await threadListItem.delete(); await aui.threads.reload(); @@ -545,6 +555,8 @@ const ThreadListItemContent: FC = ({ log.error("[ThreadList] Failed to delete thread:", error); message.error(t("chatInterface.deleteFailed")); throw error; + } finally { + setPendingThreadOperationId(undefined); } }, }); @@ -619,10 +631,7 @@ const ConversationStatusIndicatorWrapper: FC<{ const isRunning = status === "running" || status === "streaming"; return ( - + ); }; @@ -643,7 +652,7 @@ const InlineRenameEditor: FC<{ onCancel(); } }, - [title, currentTitle, onRename, onCancel], + [title, currentTitle, onRename, onCancel] ); const handleKeyDown = useCallback( @@ -652,13 +661,13 @@ const InlineRenameEditor: FC<{ onCancel(); } }, - [onCancel], + [onCancel] ); return (
- - +
+ + +
); }; diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 24b2f536a7..65f1bb03f9 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -1214,8 +1214,20 @@ const AssistantMessage: FC<{ ).metadata; const subagentId = meta?.subagentId; const runId = meta?.runId; - const chainPath: `group-${string}`[] = - part.type === "reasoning" + const isImagePart = + (part.type === "image" && + Boolean((part as { image?: string }).image)) || + (part.type === "text" && + Boolean( + (part as { + isSearchImage?: boolean; + imageSource?: SourcePartLike; + }).isSearchImage && + (part as { imageSource?: SourcePartLike }).imageSource + )); + const chainPath: `group-${string}`[] = isImagePart + ? ["group-image"] + : part.type === "reasoning" ? ["group-chainOfThought", "group-reasoning"] : part.type === "tool-call" ? ["group-chainOfThought", "group-tool"] @@ -1269,6 +1281,12 @@ const AssistantMessage: FC<{ } switch (part.type) { + case "group-image": + return ( +
+ {children} +
+ ); case "group-chainOfThought": return
{children}
; case "group-tool": @@ -1616,7 +1634,7 @@ const GlobalSearchImage: FC<{ source: SourcePartLike }> = ({ source }) => { source.title && source.title !== imageUrl ? source.title : undefined; return (
= ({ source }) => { loading="lazy" preview proxy - className="max-h-[28rem] w-full bg-muted/50 object-contain" + className="aspect-[4/3] max-h-56 w-full bg-muted/50 object-cover" /> {displayTitle || source.text ? (
diff --git a/frontend/components/agent/CreateAgentModal.tsx b/frontend/components/agent/CreateAgentModal.tsx index dea2d4288e..cc59ef9127 100644 --- a/frontend/components/agent/CreateAgentModal.tsx +++ b/frontend/components/agent/CreateAgentModal.tsx @@ -85,6 +85,7 @@ export default function CreateAgentModal({