diff --git a/web/client/src/sections/superAgent/actions/agentResources.js b/web/client/src/sections/superAgent/actions/agentResources.js index e05aeeb..dd21a32 100644 --- a/web/client/src/sections/superAgent/actions/agentResources.js +++ b/web/client/src/sections/superAgent/actions/agentResources.js @@ -170,6 +170,7 @@ export const getArtifactChapter = (artifactId, version, outlineNodeId) => method: "GET", path: superAgentServiceApiTable.artifactChapter, params: { artifactId, version, outlineNodeId }, + returnMeta: true, fallbackMessage: "获取章节失败", }); @@ -180,6 +181,7 @@ export const updateArtifactChapter = (artifactId, version, outlineNodeId, body, params: { artifactId, version, outlineNodeId }, body, headers: etag ? { "If-Match": etag } : {}, + returnMeta: true, fallbackMessage: "保存章节失败", }); diff --git a/web/client/src/sections/superAgent/container/index.jsx b/web/client/src/sections/superAgent/container/index.jsx index e8ef86e..e080310 100644 --- a/web/client/src/sections/superAgent/container/index.jsx +++ b/web/client/src/sections/superAgent/container/index.jsx @@ -510,6 +510,7 @@ const SuperAgent = () => { workspaceOpen, setWorkspaceOpen, activeSessionId, + activeConversationId, currentUserId, taskStage, actions, diff --git a/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js b/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js index 95e4d6a..a3c438f 100644 --- a/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js +++ b/web/client/src/sections/superAgent/hooks/useSuperAgentWorkspace.js @@ -18,12 +18,13 @@ import { useEffect, useRef, useState } from "react"; import { message } from "antd"; import { - getSuperAgentSessionSectionDirect, - getSuperAgentSessionSectionsDirect, - updateSuperAgentSessionSectionDirect, -} from "../actions/api"; + getArtifact, + getArtifactChapter, + listArtifactChapters, + listArtifacts, + updateArtifactChapter, +} from "../actions/agentResources"; import { defaultCatalog, defaultScoreItems, TASK_STAGE } from "../constants"; -import { htmlToMarkdown } from "../../bookWriter/utils/htmlToMarkdown"; import { exportToWord } from "../../aiTenderDocument/utils/exportToWord"; import { buildBodyExportContent, @@ -202,6 +203,7 @@ const useSuperAgentWorkspace = ({ workspaceOpen, setWorkspaceOpen, activeSessionId, + activeConversationId, currentUserId, taskStage, actions, @@ -225,6 +227,7 @@ const useSuperAgentWorkspace = ({ const sectionDetailLoadedIdsRef = useRef(new Set()); const [workspaceSectionsLoading, setWorkspaceSectionsLoading] = useState(false); const activeSessionIdRef = useRef(""); + const artifactContextRef = useRef({ artifactId: "", version: 0 }); activeSessionIdRef.current = String(activeSessionId || ""); const isCurrentSession = (sessionId) => @@ -350,14 +353,15 @@ const useSuperAgentWorkspace = ({ }) => { const sectionId = getSectionId(section) || `section_${index + 1}`; try { + const artifactId = section.artifactId || section.artifact_id || artifactContextRef.current.artifactId; + const artifactVersion = Number( + section.artifactVersion || section.artifact_version || artifactContextRef.current.version + ); + if (!artifactId || !artifactVersion) return section; const requestKey = `${sessionId}_${sectionId}_${currentUserId}`; let sectionRequest = sectionDetailRequestMap.get(requestKey); if (forceFresh || !sectionRequest) { - sectionRequest = getSuperAgentSessionSectionDirect({ - sessionId, - sectionId, - userId: currentUserId, - }).finally(() => { + sectionRequest = getArtifactChapter(artifactId, artifactVersion, sectionId).finally(() => { sectionDetailRequestMap.delete(requestKey); }); if (!forceFresh) { @@ -366,11 +370,15 @@ const useSuperAgentWorkspace = ({ } const sectionResult = await sectionRequest; const sectionPayload = sectionResult?.data || sectionResult || {}; - const sectionData = sectionPayload.section || sectionPayload; + const sectionData = sectionPayload.chapter || sectionPayload; return { ...section, ...sectionData, id: sectionId, + artifactId, + artifactVersion, + chapterVersion: Number(sectionData.version || section.chapterVersion || 0), + etag: sectionResult?.headers?.get?.("ETag") || section.etag || "", title: sectionData.title || section.title || @@ -399,13 +407,44 @@ const useSuperAgentWorkspace = ({ forceFresh = false, loadDetails = true, }) => { - const sectionsResult = await getSuperAgentSessionSectionsDirect({ - sessionId, - userId: currentUserId, - includeContent: 0, - }); - const sectionsData = sectionsResult?.data || sectionsResult; - const sectionList = getSessionSectionList(sectionsData); + const resourceConversationId = String( + activeConversationId || workspaceData?.conversationId || "" + ); + if (!resourceConversationId) return fallbackSections; + const artifactPage = await listArtifacts(resourceConversationId, { limit: 100 }); + const artifactItems = Array.isArray(artifactPage?.items) ? artifactPage.items : []; + const selectedArtifact = artifactItems + .filter((item) => item?.id && Number(item.version) > 0) + .sort((left, right) => + String(right.updated_at || right.updatedAt || "").localeCompare( + String(left.updated_at || left.updatedAt || "") + ) + )[0]; + if (!selectedArtifact) return fallbackSections; + const artifactDetail = await getArtifact( + String(selectedArtifact.id), + Number(selectedArtifact.version) + ); + artifactContextRef.current = { + artifactId: String(selectedArtifact.id), + version: Number(selectedArtifact.version), + title: artifactDetail?.title || selectedArtifact.title || "", + }; + const chapterResponse = await listArtifactChapters( + artifactContextRef.current.artifactId, + artifactContextRef.current.version + ); + const sectionList = (chapterResponse?.items || []).map((chapter, index) => ({ + ...chapter, + id: chapter.outline_node_id, + section_id: chapter.outline_node_id, + title: chapter.title || `正文${index + 1}`, + content: chapter.content || "", + artifactId: String(chapter.artifact_id || artifactContextRef.current.artifactId), + artifactVersion: Number(chapter.artifact_version || artifactContextRef.current.version), + chapterVersion: Number(chapter.version || 0), + etag: `"${Number(chapter.version || 1)}"`, + })); const sourceSections = sectionList.length ? mergeSectionContentList(fallbackSections, sectionList) : fallbackSections; @@ -1035,6 +1074,40 @@ const useSuperAgentWorkspace = ({ sectionBaselineContentRef.current.clear(); }; + const refreshSectionAfterConflict = async (sessionId, section) => { + try { + const refreshedSection = await loadSessionSectionDetail({ + sessionId, + section, + forceFresh: true, + }); + const sectionId = getSectionId(section, ""); + dirtySectionIdsRef.current.delete(sectionId); + setWorkspaceBodyList((previousList) => + mergeSectionContentList(previousList, [refreshedSection]) + ); + setWorkspaceData((previousData) => { + if (!previousData || previousData.sessionId !== sessionId) return previousData; + const nextSections = mergeSectionContentList(previousData.sections, [refreshedSection]); + return { + ...previousData, + sections: nextSections, + payload: { + ...(previousData.payload || {}), + sections: nextSections, + chapters: nextSections, + }, + }; + }); + } catch (refreshError) { + superAgentWarn("workspace.conflict", "refresh_failed", { + sessionId, + sectionId: getSectionId(section, ""), + error: refreshError?.message, + }); + } + }; + useEffect(() => { if (!activeSessionId) return; closeWorkspace(); @@ -1164,18 +1237,28 @@ const useSuperAgentWorkspace = ({ const savedIds = new Set(); for (const item of sectionsToPersist) { const sectionId = getSectionId(item, ""); - const content = htmlToMarkdown(item.content || ""); + const content = String(item.content || ""); try { if (!sectionId) throw new Error("章节缺少 section_id"); - await updateSuperAgentSessionSectionDirect({ - sessionId, + const artifactId = item.artifactId || item.artifact_id || artifactContextRef.current.artifactId; + const artifactVersion = Number( + item.artifactVersion || item.artifact_version || artifactContextRef.current.version + ); + const etag = item.etag || `"${Number(item.chapterVersion || item.version || 1)}"`; + if (!artifactId || !artifactVersion) throw new Error("章节缺少 artifact 版本信息"); + const savedResult = await updateArtifactChapter( + artifactId, + artifactVersion, sectionId, - userId: currentUserId, - content, - status: "edited", - title: item.title || item.name || "", - order: getSectionOrder(item, savedCount + 1), - }); + { + title: item.title || item.name || undefined, + content, + }, + etag + ); + const savedData = savedResult?.data || {}; + item.chapterVersion = Number(savedData.version || item.chapterVersion || 0); + item.etag = savedResult?.headers?.get?.("ETag") || `"${item.chapterVersion}"`; savedCount += 1; savedIds.add(sectionId); rememberSavedSectionContent(sessionId, item); @@ -1186,6 +1269,10 @@ const useSuperAgentWorkspace = ({ ); } catch (error) { failedCount += 1; + if (Number(error?.status) === 412) { + await refreshSectionAfterConflict(sessionId, item); + message.warning("章节已被智能体更新,请刷新后重新编辑"); + } superAgentWarn("workspace.save", "sections.persist.item_failed", { traceId, sessionId, @@ -1456,15 +1543,25 @@ const useSuperAgentWorkspace = ({ } try { - await updateSuperAgentSessionSectionDirect({ - sessionId, + const artifactId = artifactContextRef.current.artifactId; + const artifactVersion = artifactContextRef.current.version; + const currentSection = (workspaceData?.sections || []).find( + (item) => getSectionId(item, "") === sectionId + ); + const chapterId = currentSection?.artifactId || currentSection?.artifact_id || artifactId; + const chapterVersion = Number( + currentSection?.artifactVersion || currentSection?.artifact_version || artifactVersion + ); + const etag = currentSection?.etag || `"${Number(currentSection?.chapterVersion || currentSection?.version || 1)}"`; + if (!chapterId || !chapterVersion) throw new Error("章节缺少 artifact 版本信息"); + const savedResult = await updateArtifactChapter( + chapterId, + chapterVersion, sectionId, - userId: currentUserId, - content: htmlToMarkdown(content || ""), - status: "edited", - title: title || "", - order, - }); + { title: title || undefined, content: String(content || "") }, + etag + ); + const savedData = savedResult?.data || {}; rememberSavedSectionContent(sessionId, { id: sectionId, content, @@ -1473,6 +1570,10 @@ const useSuperAgentWorkspace = ({ id: sectionId, section_id: sectionId, sectionId, + artifactId: chapterId, + artifactVersion: chapterVersion, + chapterVersion: Number(savedData.version || currentSection?.chapterVersion || 0), + etag: savedResult?.headers?.get?.("ETag") || `"${Number(savedData.version || currentSection?.chapterVersion || 1)}"`, content, status: "edited", title: title || "", @@ -1557,7 +1658,12 @@ const useSuperAgentWorkspace = ({ sectionId, error, }); - message.error(error?.message || "章节保存失败,请稍后重试"); + if (Number(error?.status) === 412) { + await refreshSectionAfterConflict(sessionId, { id: sectionId }); + message.warning("章节已被智能体更新,请刷新后重新编辑"); + } else { + message.error(error?.message || "章节保存失败,请稍后重试"); + } return false; } }; diff --git a/web/client/src/sections/superAgent/utils/agentApi.js b/web/client/src/sections/superAgent/utils/agentApi.js index 4018642..02e9234 100644 --- a/web/client/src/sections/superAgent/utils/agentApi.js +++ b/web/client/src/sections/superAgent/utils/agentApi.js @@ -147,13 +147,19 @@ export const buildSuperAgentHeaders = async (extraHeaders = {}) => { }; }; -const parseAgentResponse = async (response, fallbackMessage) => { - if (response.status === 204) return null; +const parseAgentResponse = async (response, fallbackMessage, returnMeta = false) => { + if (response.status === 204) { + return returnMeta + ? { data: null, headers: response.headers, status: response.status } + : null; + } const data = await readJsonSafe(response); if (!response.ok) { throw parseAgentErrorPayload(data, response.status, fallbackMessage); } - return data; + return returnMeta + ? { data, headers: response.headers, status: response.status } + : data; }; export const requestAgentJson = async ({ @@ -168,6 +174,7 @@ export const requestAgentJson = async ({ idempotencyKey, retryOnUnauthorized = true, service = superAgentBusinessService, + returnMeta = false, } = {}) => { const resolvedPath = buildSuperAgentPath(path, params); const search = new URLSearchParams(); @@ -213,10 +220,11 @@ export const requestAgentJson = async ({ idempotencyKey: key, retryOnUnauthorized: false, service, + returnMeta, }); } - return parseAgentResponse(response, fallbackMessage); + return parseAgentResponse(response, fallbackMessage, returnMeta); }; export const withAgentAuthorization = async (init = {}) => { diff --git a/web/tests/agentApiAdapter.test.mjs b/web/tests/agentApiAdapter.test.mjs index c76a298..5efcdfb 100644 --- a/web/tests/agentApiAdapter.test.mjs +++ b/web/tests/agentApiAdapter.test.mjs @@ -91,6 +91,7 @@ test("adapter source exchanges login token for agent jwt and omits identity head const config = read("config.cjs"); const chatHook = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentChat.js"); const uploadUtils = read("client", "src", "sections", "superAgent", "utils", "attachmentUpload.js"); + const workspace = read("client", "src", "sections", "superAgent", "hooks", "useSuperAgentWorkspace.js"); assert.doesNotMatch(table, /\/api\/v1\/tasks/); assert.match(agentApi, /ACCESS_TOKEN_PATH = "\/_api\/super-agent\/access-token"/); assert.match(agentApi, /Authorization: `Bearer \$\{token\}`/); @@ -114,6 +115,14 @@ test("adapter source exchanges login token for agent jwt and omits identity head assert.match(agentApi, /getSuperAgentFrameworkApiUrl/); assert.match(resources, /completeAttachmentUpload/); assert.match(resources, /listAttachments/); + assert.match(resources, /getArtifactChapter/); + assert.match(resources, /returnMeta: true/); + assert.match(workspace, /listArtifacts\(resourceConversationId/); + assert.match(workspace, /listArtifactChapters/); + assert.match(workspace, /updateArtifactChapter/); + assert.match(resources, /If-Match/); + assert.doesNotMatch(workspace, /getSuperAgentSessionSectionsDirect/); + assert.doesNotMatch(workspace, /updateSuperAgentSessionSectionDirect/); assert.match(chatHook, /createAttachmentUploadIntent\(conversationId/); assert.match(chatHook, /completeAttachmentUpload\(uploadIntent\.attachmentId\)/); assert.match(chatHook, /listAttachments\(conversationId\)/);