diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index bed8565b..a938bb46 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { +import { ArrowDownIcon, Code2Icon, CopyIcon, @@ -14,24 +14,24 @@ import { SparklesIcon, UploadCloudIcon, XIcon, -} from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; +} from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; -import { toast } from "sonner"; -import { defaultUrlTransform } from "react-markdown"; -import { Streamdown } from "streamdown"; +import { toast } from "sonner"; +import { defaultUrlTransform } from "react-markdown"; +import { Streamdown } from "streamdown"; -import { - Artifact, +import { + Artifact, ArtifactAction, ArtifactActions, ArtifactContent, ArtifactHeader, ArtifactTitle, -} from "@/components/ai-elements/artifact"; -import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document-rewrite-dialog"; +} from "@/components/ai-elements/artifact"; +import { DocumentRewriteDialog } from "@/components/workspace/artifacts/document-rewrite-dialog"; import { Select, SelectItem } from "@/components/ui/select"; import { SelectContent, @@ -41,23 +41,23 @@ import { } from "@/components/ui/select"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; -import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor"; -import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite"; -import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research"; -import { - listDurableDocumentRewrites, - streamDurableDocumentRewriteJob, - type DocumentRewriteSummary, - type DocumentRewriteStreamEvent, -} from "@/open-canvas/api/rewrite"; +import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor"; +import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite"; +import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research"; +import { + listDurableDocumentRewrites, + streamDurableDocumentRewriteJob, + type DocumentRewriteSummary, + type DocumentRewriteStreamEvent, +} from "@/open-canvas/api/rewrite"; import { publishArtifactToKnowledge } from "@/core/agents/api"; -import { useAgent } from "@/core/agents/hooks"; -import { apiFetch } from "@/core/api"; -import { copyTextToClipboard } from "@/core/clipboard"; -import { - DOCUMENT_REWRITE_UNDONE_EVENT, - useDocumentRewritePresentation, -} from "@/core/document-rewrite/context"; +import { useAgent } from "@/core/agents/hooks"; +import { apiFetch } from "@/core/api"; +import { copyTextToClipboard } from "@/core/clipboard"; +import { + DOCUMENT_REWRITE_UNDONE_EVENT, + useDocumentRewritePresentation, +} from "@/core/document-rewrite/context"; import { downloadArtifactFile, } from "@/core/artifacts/download"; @@ -66,6 +66,7 @@ import { useUpdateArtifactContent, } from "@/core/artifacts/hooks"; import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; +import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview"; import { useI18n } from "@/core/i18n/hooks"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { streamdownPlugins } from "@/core/streamdown"; @@ -99,16 +100,16 @@ import { useArtifacts } from "./context"; import { SpreadsheetPreview } from "./spreadsheet-preview"; import { SkillInstallDialog } from "./skill-install-dialog"; -const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; - -async function sha256Content(content: string): Promise { - if (!globalThis.crypto?.subtle) return undefined; - const digest = await globalThis.crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(content), - ); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); -} +const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; + +async function sha256Content(content: string): Promise { + if (!globalThis.crypto?.subtle) return undefined; + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(content), + ); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} function extractTextFromChildren(children: ReactNode): string { if (typeof children === "string" || typeof children === "number") return String(children); @@ -151,13 +152,13 @@ export function ArtifactFileDetail({ onClose, allowVirtualMarkdownEdit = false, disableDesensitize = false, - hideFileName = false, - onDownloadClick, - toolbarActions, - headerLeadingAction, - headerLeadingActionBare = false, - researchSessionId, -}: { + hideFileName = false, + onDownloadClick, + toolbarActions, + headerLeadingAction, + headerLeadingActionBare = false, + researchSessionId, +}: { className?: string; filepath: string; threadId: string; @@ -183,22 +184,22 @@ export function ArtifactFileDetail({ * (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的 * 「另存为 Word」。默认不传 —— 其它调用方行为不变。 */ - onDownloadClick?: () => void; - /** Deep-research reports use protected `deep-research://` image references. */ - researchSessionId?: string; - /** - * 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它 - * 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。 - */ - toolbarActions?: ReactNode; - /** Replaces the left file-type badge for an embedded workspace action. */ - headerLeadingAction?: ReactNode; - /** - * 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。 - * 默认保留原有徽标容器,避免影响普通聊天和产物预览。 - */ - headerLeadingActionBare?: boolean; -}) { + onDownloadClick?: () => void; + /** Deep-research reports use protected `deep-research://` image references. */ + researchSessionId?: string; + /** + * 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它 + * 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。 + */ + toolbarActions?: ReactNode; + /** Replaces the left file-type badge for an embedded workspace action. */ + headerLeadingAction?: ReactNode; + /** + * 嵌入式工作区的标题动作可直接放在标题栏中,避免再被文件类型徽标的胶囊容器包一层。 + * 默认保留原有徽标容器,避免影响普通聊天和产物预览。 + */ + headerLeadingActionBare?: boolean; +}) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); const { isMock, thread, publicShareCode } = useThread(); @@ -295,7 +296,7 @@ export function ArtifactFileDetail({ enabled: isCodeFile && !useWriteFileStreaming, }); - const canEditArtifact = + const canEditArtifact = language === "markdown" && !useWriteFileStreaming && !isSkillFile && @@ -303,36 +304,36 @@ export function ArtifactFileDetail({ // 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个 // 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间 // 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。 - const canUseAiEditor = canEditArtifact; - const [draftContent, setDraftContent] = useState(""); - const [hasLocalEdits, setHasLocalEdits] = useState(false); - const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false); - const [documentRewritePresentationId, setDocumentRewritePresentationId] = useState(null); - const updateArtifact = useUpdateArtifactContent(); - const queryClient = useQueryClient(); - const documentRewrite = useDocumentRewrite(); - const imageCapabilities = useResearchCapabilities(); - const rewritePresentation = useDocumentRewritePresentation(); - const hydratedRewriteScopeRef = useRef(null); - const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({ - presentationId: null, - plan: "", - }); - const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({ - presentationId: null, - thinking: "", - }); - const documentRewriteActive = documentRewrite.isActive; - const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; - const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; - const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits; - const displayContent = documentRewriteActive - ? documentRewrite.run.draft - : (canEditArtifact ? draftContent : (content ?? "")); + const canUseAiEditor = canEditArtifact; + const [draftContent, setDraftContent] = useState(""); + const [hasLocalEdits, setHasLocalEdits] = useState(false); + const [documentRewriteDialogOpen, setDocumentRewriteDialogOpen] = useState(false); + const [documentRewritePresentationId, setDocumentRewritePresentationId] = useState(null); + const updateArtifact = useUpdateArtifactContent(); + const queryClient = useQueryClient(); + const documentRewrite = useDocumentRewrite(); + const imageCapabilities = useResearchCapabilities(); + const rewritePresentation = useDocumentRewritePresentation(); + const hydratedRewriteScopeRef = useRef(null); + const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({ + presentationId: null, + plan: "", + }); + const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({ + presentationId: null, + thinking: "", + }); + const documentRewriteActive = documentRewrite.isActive; + const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; + const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; + const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits; + const displayContent = documentRewriteActive + ? documentRewrite.run.draft + : (canEditArtifact ? draftContent : (content ?? "")); const isAutoPreviewLanguage = language === "html" || language === "markdown" || language === "mermaid"; - const isPreviewStreaming = - isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive); + const isPreviewStreaming = + isAutoPreviewLanguage && (thread.isLoading || documentRewriteActive); const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const fileTypeLabel = useMemo(() => { if (isImageFile(filepath)) return "IMG"; @@ -345,7 +346,7 @@ export function ArtifactFileDetail({ return "FILE"; }, [filepath, isSkillFile, isSpreadsheetFile, language]); - const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code"); + const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code"); const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true); const [installDialogOpen, setInstallDialogOpen] = useState(false); const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false); @@ -358,346 +359,346 @@ export function ArtifactFileDetail({ null, ); const artifactContentRef = useRef(null); - const resumeButtonTimeoutRef = useRef(null); - - useEffect(() => { - if (!documentRewriteActive) return; - setAllowAutoViewSwitch(false); - setAutoScrollEnabled(true); - setShowResumeAutoScroll(false); - setViewMode("code"); - }, [documentRewriteActive]); + const resumeButtonTimeoutRef = useRef(null); + + useEffect(() => { + if (!documentRewriteActive) return; + setAllowAutoViewSwitch(false); + setAutoScrollEnabled(true); + setShowResumeAutoScroll(false); + setViewMode("code"); + }, [documentRewriteActive]); useEffect(() => { setDraftContent(content ?? ""); setHasLocalEdits(false); }, [filepathFromProps]); - useEffect(() => { - if (!canEditArtifact || hasLocalEdits || documentRewriteActive) { - return; - } - setDraftContent(content ?? ""); - }, [canEditArtifact, content, documentRewriteActive, hasLocalEdits]); - - useEffect(() => { - if ( - !canEditArtifact || - !threadId || - !filepath || - isMock || - useWriteFileStreaming || - documentRewriteActive - ) { - return; - } - const scope = `${threadId}:${filepath}`; - if (hydratedRewriteScopeRef.current === scope) return; - hydratedRewriteScopeRef.current = scope; - let disposed = false; - - const updatePresentation = ( - presentationId: string, - event: DocumentRewriteStreamEvent, - ) => { - if (!rewritePresentation) return; - switch (event.type) { - case "snapshot_fixed": - rewritePresentation.update(presentationId, { - stage: "requirements", - fileName: event.fileName || getFileName(filepath), - originalCharCount: event.originalCharCount, - requirementsPlan: "", - }); - break; - case "requirements_started": - rewritePresentation.update(presentationId, { stage: "requirements", requirementsPlan: "" }); - break; - case "requirements_checkpoint": - case "requirements_completed": - rewritePresentation.update(presentationId, { - stage: "requirements", - requirementsPlan: event.content, - }); - break; - case "requirements_resolved": - rewritePresentation.update(presentationId, { - stage: "writing", - ...(event.plan ? { requirementsPlan: event.plan } : {}), - }); - break; - case "rewrite_delta": - rewritePresentation.update(presentationId, { - stage: "writing", - writtenCharCount: event.charCount, - }); - break; - case "illustration_started": - rewritePresentation.update(presentationId, { - stage: "illustrating", - illustrationTarget: event.section, - maxGeneratedImages: event.total || 1, - }); - break; - case "illustration_generated": - rewritePresentation.update(presentationId, { - stage: "illustrating", - illustrationTarget: event.section, - generatedImageCount: event.generatedCount ?? event.position, - maxGeneratedImages: event.total || 1, - }); - break; - case "illustration_completed": - rewritePresentation.update(presentationId, { - stage: "illustrating", - generatedImageCount: event.generatedCount, - maxGeneratedImages: event.requestedCount || 1, - warnings: event.warnings, - }); - break; - case "validation_started": - rewritePresentation.update(presentationId, { stage: "validation" }); - break; - case "validation_completed": - rewritePresentation.update(presentationId, { warnings: event.warnings }); - break; - case "comparison_ready": - rewritePresentation.update(presentationId, { summary: event.summary }); - break; - case "commit_started": - rewritePresentation.update(presentationId, { stage: "committing" }); - break; - case "committed": - rewritePresentation.update(presentationId, { - rewrittenContent: event.content, - versionId: event.versionId ?? null, - }); - break; - case "done": - rewritePresentation.update(presentationId, { - stage: "completed", - versionId: event.versionId ?? null, - }); - break; - case "conflict": - rewritePresentation.update(presentationId, { stage: "conflict", error: event.message }); - break; - case "cancelled": - rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message }); - break; - case "error": - rewritePresentation.update(presentationId, { stage: "error", error: event.message }); - break; - default: - break; - } - }; - - void (async () => { - try { - const jobs = await listDurableDocumentRewrites({ threadId, path: filepath }); - const latest = jobs[0]; - if (disposed || !latest) return; - // Restore prior terminal rewrites into the ordinary message timeline in - // chronological order. The newest job is handled below because it may - // still be active and needs its SSE stream resumed. - for (const historical of jobs.slice(1).reverse()) { - const historicalResult = historical.result; - const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object" - ? historicalResult.summary as DocumentRewriteSummary - : null; - const historicalPresentationId = rewritePresentation?.create({ - threadId, - source: { kind: "artifact", id: threadId, path: filepath }, - fileName: getFileName(filepath), - instruction: historical.request.instruction, - style: historical.request.style, - modelName: historical.request.modelName, - generateImages: historical.request.generateImages, - maxGeneratedImages: historical.request.maxGeneratedImages, - originalContent: historical.request.originalContent, - rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, - requirementsPlan: typeof historicalResult.requirementsPlan === "string" - ? historicalResult.requirementsPlan - : "", - }); - if (!historicalPresentationId || !rewritePresentation) continue; - rewritePresentation.update(historicalPresentationId, { - stage: historical.status === "completed" - ? historicalResult.status === "conflict" ? "conflict" : "completed" - : historical.status === "cancelled" ? "cancelled" : "error", - summary: historicalSummary, - warnings: historicalSummary?.validationWarnings ?? [], - versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null, - error: historicalResult.status === "conflict" - ? "文件在改写期间已变化,未覆盖原文件。" - : historical.status === "cancelled" - ? "已停止全文改写,原文件未被修改。" - : historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null, - }); - } - const result = latest.result; - const summary = result.summary && typeof result.summary === "object" - ? result.summary as DocumentRewriteSummary - : null; - const originalContent = latest.request.originalContent || draftContent; - const rewrittenContent = typeof result.content === "string" ? result.content : null; - const presentationId = rewritePresentation?.create({ - threadId, - source: { kind: "artifact", id: threadId, path: filepath }, - fileName: getFileName(filepath), - instruction: latest.request.instruction, - style: latest.request.style, - modelName: latest.request.modelName, - generateImages: latest.request.generateImages, - maxGeneratedImages: latest.request.maxGeneratedImages, - originalContent, - rewrittenContent, - requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", - }); - if (!presentationId || !rewritePresentation) return; - setDocumentRewritePresentationId(presentationId); - - if (latest.status === "completed") { - rewritePresentation.update(presentationId, { - stage: result.status === "conflict" ? "conflict" : "completed", - summary, - warnings: summary?.validationWarnings ?? [], - versionId: typeof result.versionId === "string" ? result.versionId : null, - error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null, - }); - if (rewrittenContent && result.status !== "conflict") { - setDraftContent(rewrittenContent); - setHasLocalEdits(false); - queryClient.setQueriesData( - { queryKey: ["artifact", filepath, threadId, isMock] }, - { content: rewrittenContent, url: undefined }, - ); - } - return; - } - if (latest.status === "failed" || latest.status === "cancelled") { - rewritePresentation.update(presentationId, { - stage: latest.status === "cancelled" ? "cancelled" : "error", - summary, - }); - return; - } - - void documentRewrite.start( - { - threadId, - path: filepath, - instruction: latest.request.instruction, - style: latest.request.style, - modelName: latest.request.modelName ?? undefined, - generateImages: latest.request.generateImages, - maxGeneratedImages: latest.request.maxGeneratedImages, - }, - { - jobId: latest.id, - stream: (signal) => streamDurableDocumentRewriteJob( - { threadId, path: filepath }, - latest.id, - signal, - ), - onEvent: (event) => updatePresentation(presentationId, event), - onCommitted: (rewritten) => { - setDraftContent(rewritten); - setHasLocalEdits(false); - rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); - queryClient.setQueriesData( - { queryKey: ["artifact", filepath, threadId, isMock] }, - { content: rewritten, url: undefined }, - ); - }, - }, - ); - } catch { - // Existing artifacts remain available even if history hydration fails. - } - })(); - return () => { - disposed = true; - }; - }, [ - canEditArtifact, - documentRewrite, - documentRewriteActive, - draftContent, - filepath, - isMock, - queryClient, - rewritePresentation, - threadId, - useWriteFileStreaming, - ]); - - // The stream hook already coalesces requirements tokens with rAF. Mirror the - // coalesced plan into the ordinary message-list card instead of forcing a - // React context write for every individual SSE delta. - useEffect(() => { - const presentationId = documentRewritePresentationId; - const plan = documentRewrite.run.requirementsPlan; - if (!presentationId || !rewritePresentation || !plan) { - return; - } - - // `update` replaces the presentation context value, which changes the - // context object in this effect's dependency list. Only write when this - // particular card actually receives new text; otherwise React would - // re-enter this effect indefinitely after each context update. - const previous = mirroredRequirementsPlanRef.current; - if (previous.presentationId === presentationId && previous.plan === plan) { - return; - } - mirroredRequirementsPlanRef.current = { presentationId, plan }; - rewritePresentation.update(presentationId, { - requirementsPlan: plan, - }); - }, [ - documentRewrite.run.requirementsPlan, - documentRewritePresentationId, - rewritePresentation, - ]); - - // Thoughts use the same rAF coalescing as the draft, then are mirrored once - // per browser frame into the regular message-list card. They never touch - // the artifact Markdown or the durable rewrite checkpoint. - useEffect(() => { - const presentationId = documentRewritePresentationId; - const thinking = documentRewrite.run.thinkingText; - if (!presentationId || !rewritePresentation || !thinking) return; - const previous = mirroredThinkingTextRef.current; - if (previous.presentationId === presentationId && previous.thinking === thinking) return; - mirroredThinkingTextRef.current = { presentationId, thinking }; - rewritePresentation.update(presentationId, { thinkingText: thinking }); - }, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]); - - useEffect(() => { - const handleRewriteUndo = (event: Event) => { - const detail = (event as CustomEvent<{ - source?: { kind?: string; id?: string; path?: string }; - content?: string; - }>).detail; - if ( - detail?.source?.kind !== "artifact" || - detail.source.id !== threadId || - detail.source.path !== filepath || - typeof detail.content !== "string" - ) { - return; - } - setDraftContent(detail.content); - setHasLocalEdits(false); - queryClient.setQueriesData( - { queryKey: ["artifact", filepath, threadId, isMock] }, - { content: detail.content, url: undefined }, - ); - }; - window.addEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo); - return () => window.removeEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo); - }, [filepath, isMock, queryClient, threadId]); + useEffect(() => { + if (!canEditArtifact || hasLocalEdits || documentRewriteActive) { + return; + } + setDraftContent(content ?? ""); + }, [canEditArtifact, content, documentRewriteActive, hasLocalEdits]); + + useEffect(() => { + if ( + !canEditArtifact || + !threadId || + !filepath || + isMock || + useWriteFileStreaming || + documentRewriteActive + ) { + return; + } + const scope = `${threadId}:${filepath}`; + if (hydratedRewriteScopeRef.current === scope) return; + hydratedRewriteScopeRef.current = scope; + let disposed = false; + + const updatePresentation = ( + presentationId: string, + event: DocumentRewriteStreamEvent, + ) => { + if (!rewritePresentation) return; + switch (event.type) { + case "snapshot_fixed": + rewritePresentation.update(presentationId, { + stage: "requirements", + fileName: event.fileName || getFileName(filepath), + originalCharCount: event.originalCharCount, + requirementsPlan: "", + }); + break; + case "requirements_started": + rewritePresentation.update(presentationId, { stage: "requirements", requirementsPlan: "" }); + break; + case "requirements_checkpoint": + case "requirements_completed": + rewritePresentation.update(presentationId, { + stage: "requirements", + requirementsPlan: event.content, + }); + break; + case "requirements_resolved": + rewritePresentation.update(presentationId, { + stage: "writing", + ...(event.plan ? { requirementsPlan: event.plan } : {}), + }); + break; + case "rewrite_delta": + rewritePresentation.update(presentationId, { + stage: "writing", + writtenCharCount: event.charCount, + }); + break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; + case "validation_started": + rewritePresentation.update(presentationId, { stage: "validation" }); + break; + case "validation_completed": + rewritePresentation.update(presentationId, { warnings: event.warnings }); + break; + case "comparison_ready": + rewritePresentation.update(presentationId, { summary: event.summary }); + break; + case "commit_started": + rewritePresentation.update(presentationId, { stage: "committing" }); + break; + case "committed": + rewritePresentation.update(presentationId, { + rewrittenContent: event.content, + versionId: event.versionId ?? null, + }); + break; + case "done": + rewritePresentation.update(presentationId, { + stage: "completed", + versionId: event.versionId ?? null, + }); + break; + case "conflict": + rewritePresentation.update(presentationId, { stage: "conflict", error: event.message }); + break; + case "cancelled": + rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message }); + break; + case "error": + rewritePresentation.update(presentationId, { stage: "error", error: event.message }); + break; + default: + break; + } + }; + + void (async () => { + try { + const jobs = await listDurableDocumentRewrites({ threadId, path: filepath }); + const latest = jobs[0]; + if (disposed || !latest) return; + // Restore prior terminal rewrites into the ordinary message timeline in + // chronological order. The newest job is handled below because it may + // still be active and needs its SSE stream resumed. + for (const historical of jobs.slice(1).reverse()) { + const historicalResult = historical.result; + const historicalSummary = historicalResult.summary && typeof historicalResult.summary === "object" + ? historicalResult.summary as DocumentRewriteSummary + : null; + const historicalPresentationId = rewritePresentation?.create({ + threadId, + source: { kind: "artifact", id: threadId, path: filepath }, + fileName: getFileName(filepath), + instruction: historical.request.instruction, + style: historical.request.style, + modelName: historical.request.modelName, + generateImages: historical.request.generateImages, + maxGeneratedImages: historical.request.maxGeneratedImages, + originalContent: historical.request.originalContent, + rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, + requirementsPlan: typeof historicalResult.requirementsPlan === "string" + ? historicalResult.requirementsPlan + : "", + }); + if (!historicalPresentationId || !rewritePresentation) continue; + rewritePresentation.update(historicalPresentationId, { + stage: historical.status === "completed" + ? historicalResult.status === "conflict" ? "conflict" : "completed" + : historical.status === "cancelled" ? "cancelled" : "error", + summary: historicalSummary, + warnings: historicalSummary?.validationWarnings ?? [], + versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null, + error: historicalResult.status === "conflict" + ? "文件在改写期间已变化,未覆盖原文件。" + : historical.status === "cancelled" + ? "已停止全文改写,原文件未被修改。" + : historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null, + }); + } + const result = latest.result; + const summary = result.summary && typeof result.summary === "object" + ? result.summary as DocumentRewriteSummary + : null; + const originalContent = latest.request.originalContent || draftContent; + const rewrittenContent = typeof result.content === "string" ? result.content : null; + const presentationId = rewritePresentation?.create({ + threadId, + source: { kind: "artifact", id: threadId, path: filepath }, + fileName: getFileName(filepath), + instruction: latest.request.instruction, + style: latest.request.style, + modelName: latest.request.modelName, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, + originalContent, + rewrittenContent, + requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", + }); + if (!presentationId || !rewritePresentation) return; + setDocumentRewritePresentationId(presentationId); + + if (latest.status === "completed") { + rewritePresentation.update(presentationId, { + stage: result.status === "conflict" ? "conflict" : "completed", + summary, + warnings: summary?.validationWarnings ?? [], + versionId: typeof result.versionId === "string" ? result.versionId : null, + error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null, + }); + if (rewrittenContent && result.status !== "conflict") { + setDraftContent(rewrittenContent); + setHasLocalEdits(false); + queryClient.setQueriesData( + { queryKey: ["artifact", filepath, threadId, isMock] }, + { content: rewrittenContent, url: undefined }, + ); + } + return; + } + if (latest.status === "failed" || latest.status === "cancelled") { + rewritePresentation.update(presentationId, { + stage: latest.status === "cancelled" ? "cancelled" : "error", + summary, + }); + return; + } + + void documentRewrite.start( + { + threadId, + path: filepath, + instruction: latest.request.instruction, + style: latest.request.style, + modelName: latest.request.modelName ?? undefined, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, + }, + { + jobId: latest.id, + stream: (signal) => streamDurableDocumentRewriteJob( + { threadId, path: filepath }, + latest.id, + signal, + ), + onEvent: (event) => updatePresentation(presentationId, event), + onCommitted: (rewritten) => { + setDraftContent(rewritten); + setHasLocalEdits(false); + rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); + queryClient.setQueriesData( + { queryKey: ["artifact", filepath, threadId, isMock] }, + { content: rewritten, url: undefined }, + ); + }, + }, + ); + } catch { + // Existing artifacts remain available even if history hydration fails. + } + })(); + return () => { + disposed = true; + }; + }, [ + canEditArtifact, + documentRewrite, + documentRewriteActive, + draftContent, + filepath, + isMock, + queryClient, + rewritePresentation, + threadId, + useWriteFileStreaming, + ]); + + // The stream hook already coalesces requirements tokens with rAF. Mirror the + // coalesced plan into the ordinary message-list card instead of forcing a + // React context write for every individual SSE delta. + useEffect(() => { + const presentationId = documentRewritePresentationId; + const plan = documentRewrite.run.requirementsPlan; + if (!presentationId || !rewritePresentation || !plan) { + return; + } + + // `update` replaces the presentation context value, which changes the + // context object in this effect's dependency list. Only write when this + // particular card actually receives new text; otherwise React would + // re-enter this effect indefinitely after each context update. + const previous = mirroredRequirementsPlanRef.current; + if (previous.presentationId === presentationId && previous.plan === plan) { + return; + } + mirroredRequirementsPlanRef.current = { presentationId, plan }; + rewritePresentation.update(presentationId, { + requirementsPlan: plan, + }); + }, [ + documentRewrite.run.requirementsPlan, + documentRewritePresentationId, + rewritePresentation, + ]); + + // Thoughts use the same rAF coalescing as the draft, then are mirrored once + // per browser frame into the regular message-list card. They never touch + // the artifact Markdown or the durable rewrite checkpoint. + useEffect(() => { + const presentationId = documentRewritePresentationId; + const thinking = documentRewrite.run.thinkingText; + if (!presentationId || !rewritePresentation || !thinking) return; + const previous = mirroredThinkingTextRef.current; + if (previous.presentationId === presentationId && previous.thinking === thinking) return; + mirroredThinkingTextRef.current = { presentationId, thinking }; + rewritePresentation.update(presentationId, { thinkingText: thinking }); + }, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]); + + useEffect(() => { + const handleRewriteUndo = (event: Event) => { + const detail = (event as CustomEvent<{ + source?: { kind?: string; id?: string; path?: string }; + content?: string; + }>).detail; + if ( + detail?.source?.kind !== "artifact" || + detail.source.id !== threadId || + detail.source.path !== filepath || + typeof detail.content !== "string" + ) { + return; + } + setDraftContent(detail.content); + setHasLocalEdits(false); + queryClient.setQueriesData( + { queryKey: ["artifact", filepath, threadId, isMock] }, + { content: detail.content, url: undefined }, + ); + }; + window.addEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo); + return () => window.removeEventListener(DOCUMENT_REWRITE_UNDONE_EVENT, handleRewriteUndo); + }, [filepath, isMock, queryClient, threadId]); useEffect(() => { setAllowAutoViewSwitch(true); @@ -760,10 +761,10 @@ export function ArtifactFileDetail({ [], ); - useEffect(() => { - if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) { - return; - } + useEffect(() => { + if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) { + return; + } const tick = () => { scrollArtifactToBottom("auto"); @@ -772,26 +773,26 @@ export function ArtifactFileDetail({ tick(); const intervalId = window.setInterval(tick, 180); return () => window.clearInterval(intervalId); - }, [ - autoScrollEnabled, - documentRewriteActive, - scrollArtifactToBottom, - thread.isLoading, - ]); - - useEffect(() => { - if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) { - return; - } + }, [ + autoScrollEnabled, + documentRewriteActive, + scrollArtifactToBottom, + thread.isLoading, + ]); + + useEffect(() => { + if (!(thread.isLoading || documentRewriteActive) || !autoScrollEnabled) { + return; + } scrollArtifactToBottom("auto"); }, [ autoScrollEnabled, - displayContent, - documentRewriteActive, - scrollArtifactToBottom, - thread.isLoading, - viewMode, + displayContent, + documentRewriteActive, + scrollArtifactToBottom, + thread.isLoading, + viewMode, ]); useEffect(() => { @@ -816,7 +817,7 @@ export function ArtifactFileDetail({ }, []); useEffect(() => { - if (isCodeFile) { + if (isCodeFile || isWriteFileVirtual || !isArtifactApiPath(filepath)) { setSpreadsheetData(null); setBinaryPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); @@ -863,7 +864,7 @@ export function ArtifactFileDetail({ return null; }); }; - }, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, publicShareCode]); + }, [filepath, threadId, isMock, isCodeFile, isSpreadsheetFile, isWriteFileVirtual, publicShareCode]); const handlePauseAutoScroll = useCallback(() => { if (!thread.isLoading || !autoScrollEnabled) { @@ -888,8 +889,8 @@ export function ArtifactFileDetail({ }, 1600); }, [scrollArtifactToBottom]); - const handleSaveArtifact = useCallback(async () => { - if (!canEditArtifact || updateArtifact.isPending || documentRewriteActive) { + const handleSaveArtifact = useCallback(async () => { + if (!canEditArtifact || updateArtifact.isPending || documentRewriteActive) { return; } try { @@ -911,192 +912,192 @@ export function ArtifactFileDetail({ filepath, isMock, threadId, - updateArtifact, - documentRewriteActive, - ]); - - const handleStartDocumentRewrite = useCallback( - async (input: { - instruction: string; - style: "formal_analysis" | "professional" | "concise" | "readable"; - modelName: string | null; - generateImages: boolean; - maxGeneratedImages: number; - }) => { - if (!canUseFullDocumentRewrite) { - toast.error( - hasLocalEdits - ? "请先保存当前 Markdown 修改后再进行全文改写。" - : "当前文件暂不支持全文改写。", - ); - return; - } - setDocumentRewriteDialogOpen(false); - setAllowAutoViewSwitch(false); - setAutoScrollEnabled(true); - setViewMode("code"); - const expectedHash = await sha256Content(draftContent); - const presentationId = rewritePresentation?.create({ - threadId, - source: { kind: "artifact", id: threadId, path: filepath }, - fileName: getFileName(filepath), - instruction: input.instruction, - style: input.style, - modelName: input.modelName, - generateImages: input.generateImages, - maxGeneratedImages: input.maxGeneratedImages, - originalContent: draftContent, - }); - setDocumentRewritePresentationId(presentationId ?? null); - void documentRewrite.start( - { - threadId, - path: filepath, - instruction: input.instruction, - style: input.style, - modelName: input.modelName ?? undefined, - generateImages: input.generateImages, - maxGeneratedImages: input.maxGeneratedImages, - expectedHash, - }, - { - onEvent: (event) => { - if (!presentationId || !rewritePresentation) return; - switch (event.type) { - case "snapshot_fixed": - rewritePresentation.update(presentationId, { - stage: "requirements", - fileName: event.fileName || getFileName(filepath), - originalCharCount: event.originalCharCount, - requirementsPlan: "", - }); - break; - case "requirements_started": - rewritePresentation.update(presentationId, { - stage: "requirements", - requirementsPlan: "", - }); - break; - case "requirements_checkpoint": - case "requirements_completed": - rewritePresentation.update(presentationId, { - stage: "requirements", - requirementsPlan: event.content, - }); - break; - case "requirements_resolved": - rewritePresentation.update(presentationId, { - stage: "writing", - ...(event.plan ? { requirementsPlan: event.plan } : {}), - }); - break; - case "rewrite_delta": - rewritePresentation.update(presentationId, { - stage: "writing", - writtenCharCount: event.charCount, - }); - break; - case "illustration_started": - rewritePresentation.update(presentationId, { - stage: "illustrating", - illustrationTarget: event.section, - maxGeneratedImages: event.total || 1, - }); - break; - case "illustration_generated": - rewritePresentation.update(presentationId, { - stage: "illustrating", - illustrationTarget: event.section, - generatedImageCount: event.generatedCount ?? event.position, - maxGeneratedImages: event.total || 1, - }); - break; - case "illustration_completed": - rewritePresentation.update(presentationId, { - stage: "illustrating", - generatedImageCount: event.generatedCount, - maxGeneratedImages: event.requestedCount || 1, - warnings: event.warnings, - }); - break; - case "validation_started": - rewritePresentation.update(presentationId, { stage: "validation" }); - break; - case "validation_completed": - rewritePresentation.update(presentationId, { warnings: event.warnings }); - break; - case "comparison_ready": - rewritePresentation.update(presentationId, { summary: event.summary }); - break; - case "commit_started": - rewritePresentation.update(presentationId, { stage: "committing" }); - break; - case "committed": - rewritePresentation.update(presentationId, { - versionId: event.versionId ?? null, - }); - break; - case "done": - rewritePresentation.update(presentationId, { - stage: "completed", - versionId: event.versionId ?? null, - }); - break; - case "conflict": - rewritePresentation.update(presentationId, { - stage: "conflict", - error: event.message, - }); - break; - case "error": - rewritePresentation.update(presentationId, { - stage: "error", - error: event.message, - }); - break; - default: - break; - } - }, - onCommitted: (rewritten) => { - setDraftContent(rewritten); - setHasLocalEdits(false); - if (presentationId && rewritePresentation) { - rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); - } - queryClient.setQueriesData( - { - queryKey: ["artifact", filepath, threadId, isMock], - }, - { content: rewritten, url: undefined }, - ); - toast.success("Markdown 全文改写完成,已安全替换原文件。"); - }, - }, - ); - }, - [ - canUseFullDocumentRewrite, - draftContent, - documentRewrite, - filepath, - hasLocalEdits, - isMock, - queryClient, - rewritePresentation, - threadId, - ], - ); - - const handleCancelDocumentRewrite = useCallback(() => { - documentRewrite.cancel(); - if (documentRewritePresentationId && rewritePresentation) { - rewritePresentation.update(documentRewritePresentationId, { - stage: "cancelled", - error: "已停止全文改写,原文件未被修改。", - }); - } - }, [documentRewrite, documentRewritePresentationId, rewritePresentation]); + updateArtifact, + documentRewriteActive, + ]); + + const handleStartDocumentRewrite = useCallback( + async (input: { + instruction: string; + style: "formal_analysis" | "professional" | "concise" | "readable"; + modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; + }) => { + if (!canUseFullDocumentRewrite) { + toast.error( + hasLocalEdits + ? "请先保存当前 Markdown 修改后再进行全文改写。" + : "当前文件暂不支持全文改写。", + ); + return; + } + setDocumentRewriteDialogOpen(false); + setAllowAutoViewSwitch(false); + setAutoScrollEnabled(true); + setViewMode("code"); + const expectedHash = await sha256Content(draftContent); + const presentationId = rewritePresentation?.create({ + threadId, + source: { kind: "artifact", id: threadId, path: filepath }, + fileName: getFileName(filepath), + instruction: input.instruction, + style: input.style, + modelName: input.modelName, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, + originalContent: draftContent, + }); + setDocumentRewritePresentationId(presentationId ?? null); + void documentRewrite.start( + { + threadId, + path: filepath, + instruction: input.instruction, + style: input.style, + modelName: input.modelName ?? undefined, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, + expectedHash, + }, + { + onEvent: (event) => { + if (!presentationId || !rewritePresentation) return; + switch (event.type) { + case "snapshot_fixed": + rewritePresentation.update(presentationId, { + stage: "requirements", + fileName: event.fileName || getFileName(filepath), + originalCharCount: event.originalCharCount, + requirementsPlan: "", + }); + break; + case "requirements_started": + rewritePresentation.update(presentationId, { + stage: "requirements", + requirementsPlan: "", + }); + break; + case "requirements_checkpoint": + case "requirements_completed": + rewritePresentation.update(presentationId, { + stage: "requirements", + requirementsPlan: event.content, + }); + break; + case "requirements_resolved": + rewritePresentation.update(presentationId, { + stage: "writing", + ...(event.plan ? { requirementsPlan: event.plan } : {}), + }); + break; + case "rewrite_delta": + rewritePresentation.update(presentationId, { + stage: "writing", + writtenCharCount: event.charCount, + }); + break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; + case "validation_started": + rewritePresentation.update(presentationId, { stage: "validation" }); + break; + case "validation_completed": + rewritePresentation.update(presentationId, { warnings: event.warnings }); + break; + case "comparison_ready": + rewritePresentation.update(presentationId, { summary: event.summary }); + break; + case "commit_started": + rewritePresentation.update(presentationId, { stage: "committing" }); + break; + case "committed": + rewritePresentation.update(presentationId, { + versionId: event.versionId ?? null, + }); + break; + case "done": + rewritePresentation.update(presentationId, { + stage: "completed", + versionId: event.versionId ?? null, + }); + break; + case "conflict": + rewritePresentation.update(presentationId, { + stage: "conflict", + error: event.message, + }); + break; + case "error": + rewritePresentation.update(presentationId, { + stage: "error", + error: event.message, + }); + break; + default: + break; + } + }, + onCommitted: (rewritten) => { + setDraftContent(rewritten); + setHasLocalEdits(false); + if (presentationId && rewritePresentation) { + rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); + } + queryClient.setQueriesData( + { + queryKey: ["artifact", filepath, threadId, isMock], + }, + { content: rewritten, url: undefined }, + ); + toast.success("Markdown 全文改写完成,已安全替换原文件。"); + }, + }, + ); + }, + [ + canUseFullDocumentRewrite, + draftContent, + documentRewrite, + filepath, + hasLocalEdits, + isMock, + queryClient, + rewritePresentation, + threadId, + ], + ); + + const handleCancelDocumentRewrite = useCallback(() => { + documentRewrite.cancel(); + if (documentRewritePresentationId && rewritePresentation) { + rewritePresentation.update(documentRewritePresentationId, { + stage: "cancelled", + error: "已停止全文改写,原文件未被修改。", + }); + } + }, [documentRewrite, documentRewritePresentationId, rewritePresentation]); // 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体 // 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。 @@ -1211,18 +1212,18 @@ export function ArtifactFileDetail({
-
- {headerLeadingAction ?? ( - - {fileTypeLabel} - - )} +
+ {headerLeadingAction ?? ( + + {fileTypeLabel} + + )} {!hideFileName && ( {/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下 @@ -1272,7 +1273,7 @@ export function ArtifactFileDetail({ } }} > - {canUseAiEditor && !documentRewriteActive && ( + {canUseAiEditor && !documentRewriteActive && ( void handlePublishToKnowledge()} /> )} - {canEditArtifact && ( - - )} - {toolbarActions} - {canEditArtifact && ( - setDocumentRewriteDialogOpen(true)} - /> - )} + {canEditArtifact && ( + + )} + {toolbarActions} + {canEditArtifact && ( + setDocumentRewriteDialogOpen(true)} + /> + )} {!useWriteFileStreaming && filepath.endsWith(".skill") && ( - {isApplyingEdit && ( + {isApplyingEdit && (
正在改写 Markdown
- )} - {documentRewriteActive && ( -
- - {documentRewrite.run.stage === "requirements" - ? "正在理解改写需求,原文件未被修改" - : documentRewrite.run.stage === "validation" - ? "正在校验 Markdown" - : documentRewrite.run.stage === "committing" - ? "正在安全替换原文件" - : "正在写入临时草稿 · " + - documentRewrite.run.writtenCharCount.toLocaleString() + - " 字"} - -
- )} + )} + {documentRewriteActive && ( +
+ + {documentRewrite.run.stage === "requirements" + ? "正在理解改写需求,原文件未被修改" + : documentRewrite.run.stage === "validation" + ? "正在校验 Markdown" + : documentRewrite.run.stage === "committing" + ? "正在安全替换原文件" + : "正在写入临时草稿 · " + + documentRewrite.run.writtenCharCount.toLocaleString() + + " 字"} + +
+ )} {showResumeAutoScroll && (
))} - - - + filepath={filepath} + /> + + ); } -function SandboxPreviewImage({ - src, - alt, - threadId, - publicShareCode, - researchSessionId, - ...props -}: React.ImgHTMLAttributes & { - threadId: string; - publicShareCode?: string; - researchSessionId?: string; -}) { - if (!src || typeof src !== "string") { - return null; - } - - if (src.startsWith("deep-research://")) { - return researchSessionId ? ( - - ) : null; - } +function SandboxPreviewImage({ + src, + alt, + threadId, + publicShareCode, + researchSessionId, + ...props +}: React.ImgHTMLAttributes & { + threadId: string; + publicShareCode?: string; + researchSessionId?: string; +}) { + if (!src || typeof src !== "string") { + return null; + } + + if (src.startsWith("deep-research://")) { + return researchSessionId ? ( + + ) : null; + } let url = src; if (src.startsWith("/mnt/")) { @@ -1619,108 +1620,108 @@ function SandboxPreviewImage({ src={url} alt={alt} /> - ); -} - -/** Fetches a private deep-research image with the normal auth client. */ -function ProtectedResearchReportImage({ - sessionId, - src, - alt, - ...props -}: React.ImgHTMLAttributes & { - sessionId: string; -}) { - const [objectUrl, setObjectUrl] = useState(null); - const [loadFailed, setLoadFailed] = useState(false); - const [retryKey, setRetryKey] = useState(0); - - useEffect(() => { - setObjectUrl(null); - setLoadFailed(false); - if (!src?.startsWith("deep-research://")) { - return; - } - const name = src.slice("deep-research://".length); - if (!name) { - setLoadFailed(true); - return; - } - const controller = new AbortController(); - let nextUrl: string | null = null; - let disposed = false; - void (async () => { - try { - const response = await apiFetch( - `${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`, - { method: "GET", signal: controller.signal }, - ); - if (!response.ok) { - throw new Error(`Unable to load report image (${response.status})`); - } - const blob = await response.blob(); - if (!blob.size) { - throw new Error("Report image is empty"); - } - nextUrl = URL.createObjectURL(blob); - if (disposed) return; - setObjectUrl(nextUrl); - } catch (error) { - if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) { - // Never let an optional illustration break Markdown rendering, but don't - // leave the user with a misleading infinite loading label either. - setLoadFailed(true); - } - } - })(); - return () => { - disposed = true; - controller.abort(); - if (nextUrl) URL.revokeObjectURL(nextUrl); - }; - }, [retryKey, sessionId, src]); - - if (!objectUrl) { - if (loadFailed) { - return ( - - 报告配图加载失败 - - - ); - } - return 正在加载报告配图…; - } - return ( - {alt - ); -} + ); +} + +/** Fetches a private deep-research image with the normal auth client. */ +function ProtectedResearchReportImage({ + sessionId, + src, + alt, + ...props +}: React.ImgHTMLAttributes & { + sessionId: string; +}) { + const [objectUrl, setObjectUrl] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [retryKey, setRetryKey] = useState(0); + + useEffect(() => { + setObjectUrl(null); + setLoadFailed(false); + if (!src?.startsWith("deep-research://")) { + return; + } + const name = src.slice("deep-research://".length); + if (!name) { + setLoadFailed(true); + return; + } + const controller = new AbortController(); + let nextUrl: string | null = null; + let disposed = false; + void (async () => { + try { + const response = await apiFetch( + `${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`, + { method: "GET", signal: controller.signal }, + ); + if (!response.ok) { + throw new Error(`Unable to load report image (${response.status})`); + } + const blob = await response.blob(); + if (!blob.size) { + throw new Error("Report image is empty"); + } + nextUrl = URL.createObjectURL(blob); + if (disposed) return; + setObjectUrl(nextUrl); + } catch (error) { + if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) { + // Never let an optional illustration break Markdown rendering, but don't + // leave the user with a misleading infinite loading label either. + setLoadFailed(true); + } + } + })(); + return () => { + disposed = true; + controller.abort(); + if (nextUrl) URL.revokeObjectURL(nextUrl); + }; + }, [retryKey, sessionId, src]); + + if (!objectUrl) { + if (loadFailed) { + return ( + + 报告配图加载失败 + + + ); + } + return 正在加载报告配图…; + } + return ( + {alt + ); +} export function ArtifactFilePreview({ content, language, - threadId, - publicShareCode, - researchSessionId, - animated = false, + threadId, + publicShareCode, + researchSessionId, + animated = false, activeCursor = false, }: { content: string; language: string; - threadId: string; - publicShareCode?: string; - researchSessionId?: string; + threadId: string; + publicShareCode?: string; + researchSessionId?: string; animated?: boolean; activeCursor?: boolean; }) { @@ -1784,7 +1785,7 @@ export function ArtifactFilePreview({ ); }, }; - }, [content, publicShareCode, researchSessionId, threadId]); + }, [content, publicShareCode, researchSessionId, threadId]); useEffect(() => { if (language !== "html") { @@ -1849,26 +1850,26 @@ export function ArtifactFilePreview({ animated && "animate-[sandbox-preview-enter_320ms_ease-out]", )} > - - url.startsWith("deep-research://") - ? url - : defaultUrlTransform(url) - } - components={{ + + url.startsWith("deep-research://") + ? url + : defaultUrlTransform(url) + } + components={{ ...markdownComponents, img: (props) => ( ), }} diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 634deb28..f94c157c 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -1,21 +1,21 @@ import type { Message } from "@langchain/langgraph-sdk"; import { - BookOpenTextIcon, - CheckCircle2Icon, - ChevronDownIcon, - ChevronUp, + BookOpenTextIcon, + CheckCircle2Icon, + ChevronDownIcon, + ChevronUp, FolderOpenIcon, GlobeIcon, - LightbulbIcon, - ListTodoIcon, - Loader2Icon, + LightbulbIcon, + ListTodoIcon, + Loader2Icon, MessageCircleQuestionMarkIcon, NotebookPenIcon, SearchIcon, SquareTerminalIcon, WrenchIcon, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ChainOfThought, @@ -31,20 +31,20 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { - Dialog, +import { + Dialog, DialogContent, DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - buildWriteFileVirtualUrl, - hasCompleteArtifactExtension, - normalizeStreamingArtifactPath, - provisionalWriteFilePath, -} from "@/core/artifacts/streaming-write-preview"; -import { useI18n } from "@/core/i18n/hooks"; + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + buildWriteFileVirtualUrl, + hasCompleteArtifactExtension, + normalizeStreamingArtifactPath, + provisionalWriteFilePath, +} from "@/core/artifacts/streaming-write-preview"; +import { useI18n } from "@/core/i18n/hooks"; import { normalizeSearchResults, resolveSearchQuery, @@ -58,13 +58,13 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; import { - buildSkillDisplayMap, - findDisplayConfigForTool, - isDisplaySamplePayload, - normalizeDefaultDisplaySources, - shouldUseDefaultCitationMode, - type RuntimeDisplaySource, -} from "@/core/skill-display/runtime"; + buildSkillDisplayMap, + findDisplayConfigForTool, + isDisplaySamplePayload, + normalizeDefaultDisplaySources, + shouldUseDefaultCitationMode, + type RuntimeDisplaySource, +} from "@/core/skill-display/runtime"; import { normalizeDisplaySources } from "@/core/skill-display/display-adapter"; import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { env } from "@/env"; @@ -235,7 +235,7 @@ export function MessageGroup({ ); } -function ToolCall({ +function ToolCall({ id, messageId, name, @@ -257,45 +257,45 @@ function ToolCall({ ragModeEnabled?: boolean; }) { const { t } = useI18n(); - const displayMatch = - result !== undefined && isDisplaySamplePayload(result) - ? null - : (findDisplayConfigForTool(name, skillDisplayMap, args) ?? - null); + const displayMatch = + result !== undefined && isDisplaySamplePayload(result) + ? null + : (findDisplayConfigForTool(name, skillDisplayMap, args) ?? + null); const displayItems = displayMatch && result !== undefined - ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ - ...item, - index: index + 1, - skillName: displayMatch.skillName, - mode: item.displayMode ?? displayMatch.display.mode, - })) + ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ + ...item, + index: index + 1, + skillName: displayMatch.skillName, + mode: item.displayMode ?? displayMatch.display.mode, + })) : []; - if (displayItems.length > 0) { + if (displayItems.length > 0) { return ( ); - } - - // Deep Research uses a virtual tool call for each durable writing phase. - // Rendering it here keeps its lifecycle in the very same ChainOfThought - // component as ordinary Q&A retrieval and sandbox tool calls. - if (name === "deep_research_progress") { - return ; - } - - const searchItems = normalizeSearchResults(result); - if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) { + } + + // Deep Research uses a virtual tool call for each durable writing phase. + // Rendering it here keeps its lifecycle in the very same ChainOfThought + // component as ordinary Q&A retrieval and sandbox tool calls. + if (name === "deep_research_progress") { + return ; + } + + const searchItems = normalizeSearchResults(result); + if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) { const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({ ...item, index: index + 1, @@ -308,19 +308,19 @@ function ToolCall({ ); } - if (name === "web_search" || searchItems.length > 0) { - const query = resolveSearchQuery(args, result); - let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; - if (query) { - label = - name === "web_search" - ? `知识库检索 "${query}"` - : t.toolCalls.searchFor(query); - } else if (name === "web_search") { - label = "知识库检索"; - } - return ( - + if (name === "web_search" || searchItems.length > 0) { + const query = resolveSearchQuery(args, result); + let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; + if (query) { + label = + name === "web_search" + ? `知识库检索 "${query}"` + : t.toolCalls.searchFor(query); + } else if (name === "web_search") { + label = "知识库检索"; + } + return ( + {searchItems.length > 0 && ( )} @@ -437,18 +437,18 @@ function ToolCall({ if (!description) { description = t.toolCalls.writeFile; } - return ( - - ); + return ( + + ); } else if (name === "bash") { const description: string | undefined = (args as { description: string }) ?.description; @@ -509,161 +509,164 @@ function ToolCall({ icon={WrenchIcon} > ); - } -} - -function DeepResearchProgressStep({ - id, - args, -}: { - id?: string; - args: Record; -}) { - const label = typeof args.label === "string" && args.label.trim() - ? args.label - : "正在撰写研究报告"; - const detail = typeof args.detail === "string" ? args.detail.trim() : ""; - const thinking = typeof args.thinking === "string" ? args.thinking.trim() : ""; - const active = args.status !== "complete"; - const [thinkingOpen, setThinkingOpen] = useState(false); - - useEffect(() => { - if (active && thinking) setThinkingOpen(true); - }, [active, thinking]); - - return ( - - : } - label={ - - {label} - - {active ? "进行中" : "已完成"} - - - } - > - {detail && ( -
- {detail} -
- )} - {thinking && ( - - - - - -

- {active ? "模型思考内容正在更新" : "模型思考内容已完成"} -

-
-              {thinking}
-            
-
-
- )} -
- ); -} - -function ArtifactWriteToolCall({ - id, - messageId, - name, - args, - result, - description, - isLast, - isLoading, -}: { - id?: string; - messageId?: string; - name: "write_file" | "str_replace"; - args: Record; - result?: string | Record; - description: string; - isLast: boolean; - isLoading: boolean; -}) { - const artifacts = useArtifactsOptional(); - const openStreamedArtifact = artifacts?.openStreamedArtifact; - const rawPath = typeof args.path === "string" ? args.path : ""; - const content = typeof args.content === "string" ? args.content : ""; - const normalizedPath = normalizeStreamingArtifactPath(rawPath); - const hasUsablePath = - Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath); - const sourcePath = hasUsablePath - ? normalizedPath - : name === "write_file" && content.trim() - ? provisionalWriteFilePath(content) - : null; - const previewKey = messageId && id ? `${messageId}:${id}` : null; - const artifactPath = - sourcePath && name === "write_file" && messageId && id - ? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id }) - : sourcePath; - - useEffect(() => { - if ( - !openStreamedArtifact || - !artifactPath || - !previewKey || - !isLoading || - !isLast || - result !== undefined - ) { - return; - } - openStreamedArtifact(artifactPath, previewKey); - }, [ - artifactPath, - isLast, - isLoading, - openStreamedArtifact, - previewKey, - result, - ]); - - return ( - { - if (!artifactPath || !artifacts) return; - artifacts.select(artifactPath); - artifacts.setOpen(true); - }} - > - {(normalizedPath || sourcePath) && ( - - {normalizedPath || sourcePath} - - )} - - ); -} - -interface GenericCoTStep { - id?: string; - messageId?: string; + } +} + +function DeepResearchProgressStep({ + id, + args, +}: { + id?: string; + args: Record; +}) { + const label = typeof args.label === "string" && args.label.trim() + ? args.label + : "正在撰写研究报告"; + const detail = typeof args.detail === "string" ? args.detail.trim() : ""; + const thinking = typeof args.thinking === "string" ? args.thinking.trim() : ""; + const active = args.status !== "complete"; + const [thinkingOpen, setThinkingOpen] = useState(false); + + useEffect(() => { + if (active && thinking) setThinkingOpen(true); + }, [active, thinking]); + + return ( + + : } + label={ + + {label} + + {active ? "进行中" : "已完成"} + + + } + > + {detail && ( +
+ {detail} +
+ )} + {thinking && ( + + + + + +

+ {active ? "模型思考内容正在更新" : "模型思考内容已完成"} +

+
+              {thinking}
+            
+
+
+ )} +
+ ); +} + +function ArtifactWriteToolCall({ + id, + messageId, + name, + args, + result, + description, + isLast, + isLoading, +}: { + id?: string; + messageId?: string; + name: "write_file" | "str_replace"; + args: Record; + result?: string | Record; + description: string; + isLast: boolean; + isLoading: boolean; +}) { + const artifacts = useArtifactsOptional(); + const openStreamedArtifact = artifacts?.openStreamedArtifact; + const rawPath = typeof args.path === "string" ? args.path : ""; + const content = typeof args.content === "string" ? args.content : ""; + const normalizedPath = normalizeStreamingArtifactPath(rawPath); + const hasUsablePath = + Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath); + const sourcePath = hasUsablePath + ? normalizedPath + : name === "write_file" && content.trim() + ? provisionalWriteFilePath(content) + : null; + const previewKey = messageId && id ? `${messageId}:${id}` : null; + // Never select a bare provisional name like `未命名文件.md` — the artifacts + // API only serves `/mnt/user-data/...` paths and would 400. Wait until the + // tool call has ids so we can open a write-file: memory preview instead. + const artifactPath = + sourcePath && name === "write_file" && messageId && id + ? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id }) + : null; + + useEffect(() => { + if ( + !openStreamedArtifact || + !artifactPath || + !previewKey || + !isLoading || + !isLast || + result !== undefined + ) { + return; + } + openStreamedArtifact(artifactPath, previewKey); + }, [ + artifactPath, + isLast, + isLoading, + openStreamedArtifact, + previewKey, + result, + ]); + + return ( + { + if (!artifactPath || !artifacts) return; + artifacts.select(artifactPath); + artifacts.setOpen(true); + }} + > + {(normalizedPath || sourcePath) && ( + + {normalizedPath || sourcePath} + + )} + + ); +} + +interface GenericCoTStep { + id?: string; + messageId?: string; type: T; } @@ -766,7 +769,7 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) { open && "rotate-180", )} /> - {results.length} 条真实返回结果 + {results.length} 条真实返回结果 {results.map((item) => ( diff --git a/frontend-web/src/core/artifacts/hooks.ts b/frontend-web/src/core/artifacts/hooks.ts index 0e9818ac..0f76854f 100644 --- a/frontend-web/src/core/artifacts/hooks.ts +++ b/frontend-web/src/core/artifacts/hooks.ts @@ -6,6 +6,7 @@ import { useThread } from "@/components/workspace/messages/context"; import { isImageFile } from "../utils/files"; import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader"; +import { isArtifactApiPath } from "./streaming-write-preview"; import { updateArtifactContent } from "./api"; function isWriteFileStreamingUrl(filepath: string): boolean { @@ -101,7 +102,7 @@ export function useArtifactContent({ queryFn: () => { return loadArtifactContent({ filepath, threadId, isMock, publicShareCode }); }, - enabled, + enabled: Boolean(enabled) && !useWriteFileStreaming && isArtifactApiPath(filepath), refetchInterval: thread.isLoading ? 1000 : false, // Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction) staleTime: 5 * 60 * 1000, diff --git a/frontend-web/src/core/artifacts/streaming-write-preview.test.ts b/frontend-web/src/core/artifacts/streaming-write-preview.test.ts new file mode 100644 index 00000000..76f394c7 --- /dev/null +++ b/frontend-web/src/core/artifacts/streaming-write-preview.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isArtifactApiPath, + isProvisionalStreamingArtifactPath, + provisionalWriteFilePath, +} from "./streaming-write-preview"; + +test("provisionalWriteFilePath uses html only for html documents", () => { + assert.equal(provisionalWriteFilePath("# title"), "未命名文件.md"); + assert.equal(provisionalWriteFilePath(""), "未命名文件.html"); + assert.equal(provisionalWriteFilePath(""), "未命名文件.html"); +}); + +test("isProvisionalStreamingArtifactPath matches the streaming placeholder names", () => { + assert.equal(isProvisionalStreamingArtifactPath("未命名文件.md"), true); + assert.equal(isProvisionalStreamingArtifactPath("/未命名文件.html"), true); + assert.equal(isProvisionalStreamingArtifactPath("mnt/user-data/outputs/report.md"), false); +}); + +test("isArtifactApiPath rejects write-file URLs and placeholder names", () => { + assert.equal(isArtifactApiPath("未命名文件.md"), false); + assert.equal( + isArtifactApiPath( + "write-file:未命名文件.md?message_id=m1&tool_call_id=t1&language=markdown", + ), + false, + ); + assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true); + assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true); +}); diff --git a/frontend-web/src/core/artifacts/streaming-write-preview.ts b/frontend-web/src/core/artifacts/streaming-write-preview.ts index bccccd5b..c2ec791f 100644 --- a/frontend-web/src/core/artifacts/streaming-write-preview.ts +++ b/frontend-web/src/core/artifacts/streaming-write-preview.ts @@ -23,6 +23,11 @@ export function hasCompleteArtifactExtension(path: string): boolean { return /\.[a-z0-9]{2,12}$/i.test(filename); } +export const PROVISIONAL_WRITE_FILE_NAMES = new Set([ + "未命名文件.md", + "未命名文件.html", +]); + export function provisionalWriteFilePath(content: string): string { const head = content.slice(0, 256).trimStart().toLowerCase(); if (head.startsWith("; - onStart?: (threadId: string) => void; - onFinish?: (state: AgentThreadState) => void; - onToolStart?: (event: ToolStartEvent) => void; - onToolEnd?: (event: ToolEndEvent) => void; -}; + onStart?: (threadId: string) => void; + onFinish?: (state: AgentThreadState) => void; + onToolStart?: (event: ToolStartEvent) => void; + onToolEnd?: (event: ToolEndEvent) => void; +}; -type SendMessageOptions = { - additionalKwargs?: Record; - /** - * Extra human-context messages submitted before the visible user message. - * They are persisted with `hide_from_ui` so the model receives rich business - * context without dumping long prompts into the conversation surface. - */ - hiddenContext?: - | string - | { - text: string; - name?: string; - additionalKwargs?: Record; - } - | Array<{ - text: string; - name?: string; - additionalKwargs?: Record; - }>; -}; - -type ThreadTitleRefreshState = Pick & { - title_provisional?: boolean; -}; - -function normalizeHiddenContextMessages(options?: SendMessageOptions) { - const value = options?.hiddenContext; - if (!value) return []; - const items = - typeof value === "string" - ? [{ text: value }] - : Array.isArray(value) - ? value - : [value]; - return items - .map((item) => ({ - ...item, - text: item.text.trim(), - })) - .filter((item) => item.text.length > 0); -} +type SendMessageOptions = { + additionalKwargs?: Record; + /** + * Extra human-context messages submitted before the visible user message. + * They are persisted with `hide_from_ui` so the model receives rich business + * context without dumping long prompts into the conversation surface. + */ + hiddenContext?: + | string + | { + text: string; + name?: string; + additionalKwargs?: Record; + } + | Array<{ + text: string; + name?: string; + additionalKwargs?: Record; + }>; +}; + +type ThreadTitleRefreshState = Pick & { + title_provisional?: boolean; +}; + +function normalizeHiddenContextMessages(options?: SendMessageOptions) { + const value = options?.hiddenContext; + if (!value) return []; + const items = + typeof value === "string" + ? [{ text: value }] + : Array.isArray(value) + ? value + : [value]; + return items + .map((item) => ({ + ...item, + text: item.text.trim(), + })) + .filter((item) => item.text.length > 0); +} function normalizeStoredRunId(runId: string | null): string | null { if (!runId) { @@ -206,7 +210,7 @@ export async function stopThreadRun( } } -function getStreamErrorMessage(error: unknown): string { +function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; } @@ -226,52 +230,52 @@ function getStreamErrorMessage(error: unknown): string { return nestedError; } } - return "Request failed."; -} - -const TERMINAL_RUN_STATUSES = new Set([ - "success", - "error", - "timeout", - "interrupted", -]); - -// A completed server run normally sends a terminal SSE event. In local -// development, however, a Vite hot update, a proxy disconnect, or a brief -// network interruption can make the browser miss that final event while the -// backend continues (and successfully completes) the run. Do not probe on -// every render: only treat the stream as suspicious after it has been quiet -// for a while, then reconcile it from the authoritative run/state endpoints. -const STREAM_RECONCILIATION_IDLE_MS = 12_000; -const STREAM_RECONCILIATION_POLL_MS = 5_000; -// A run can legitimately be quiet while a long tool call is in progress, so -// reconnect more conservatively than the terminal-status check above. The -// reconnection only replaces the local SSE reader; the backend run keeps going. -const STREAM_RECONNECT_IDLE_MS = 30_000; -const STREAM_RECONNECT_MAX_ATTEMPTS = 3; - -async function fetchThreadTitleRefreshState( - threadId: string, - signal?: AbortSignal, -): Promise { - const response = await apiFetch( - `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`, - { signal }, - ); - if (!response.ok) { - return null; - } - const json = (await response.json()) as { - values?: { title?: string; title_provisional?: boolean }; - }; - if (!json.values) { - return null; - } - return { - title: json.values.title ?? "", - title_provisional: json.values.title_provisional, - }; -} + return "Request failed."; +} + +const TERMINAL_RUN_STATUSES = new Set([ + "success", + "error", + "timeout", + "interrupted", +]); + +// A completed server run normally sends a terminal SSE event. In local +// development, however, a Vite hot update, a proxy disconnect, or a brief +// network interruption can make the browser miss that final event while the +// backend continues (and successfully completes) the run. Do not probe on +// every render: only treat the stream as suspicious after it has been quiet +// for a while, then reconcile it from the authoritative run/state endpoints. +const STREAM_RECONCILIATION_IDLE_MS = 12_000; +const STREAM_RECONCILIATION_POLL_MS = 5_000; +// A run can legitimately be quiet while a long tool call / slow model write is +// in progress. Reconnect only after a long stall; replacing the local reader +// must NOT cancel the backend run (see reconnect below). +const STREAM_RECONNECT_IDLE_MS = 90_000; +const STREAM_RECONNECT_MAX_ATTEMPTS = 3; + +async function fetchThreadTitleRefreshState( + threadId: string, + signal?: AbortSignal, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/state`, + { signal }, + ); + if (!response.ok) { + return null; + } + const json = (await response.json()) as { + values?: { title?: string; title_provisional?: boolean }; + }; + if (!json.values) { + return null; + } + return { + title: json.values.title ?? "", + title_provisional: json.values.title_provisional, + }; +} /** * Detect "thread not found" / forbidden errors from the LangGraph SDK. @@ -319,37 +323,37 @@ function isRunNotFoundError(message: string): boolean { * 「already has an active run」。匹配 HTTP 409、英文兜底文案,或后端已中文化 * 的提示,统一映射成一句友好的中文。 */ -function isActiveRunConflictError(message: string): boolean { - return ( - /\b409\b/.test(message) || - /already has an active run/i.test(message) || - /还在生成回答/.test(message) - ); -} - -function isModelConcurrencyLimitError(message: string): boolean { - return ( - /\b429\b/.test(message) || - /并发已达到上限/.test(message) || - /并发调用已达到上限/.test(message) || - /使用人数较多/.test(message) || - /too many requests/i.test(message) - ); -} - -function friendlySendLimitMessage(message: string): string | null { - if (isActiveRunConflictError(message)) { - return "当前对话还在生成回答,请等待本轮回复结束,或先点“停止”再发送新的问题。"; - } - if (isModelConcurrencyLimitError(message)) { - return /当前账号对该模型/.test(message) - ? "当前账号对该模型的并发调用已达到上限,请等待已有回答结束后再试。" - : "当前使用人数较多,请稍等一下再试。"; - } - return null; -} - -function buildCurrentUserLabel(): string { +function isActiveRunConflictError(message: string): boolean { + return ( + /\b409\b/.test(message) || + /already has an active run/i.test(message) || + /还在生成回答/.test(message) + ); +} + +function isModelConcurrencyLimitError(message: string): boolean { + return ( + /\b429\b/.test(message) || + /并发已达到上限/.test(message) || + /并发调用已达到上限/.test(message) || + /使用人数较多/.test(message) || + /too many requests/i.test(message) + ); +} + +function friendlySendLimitMessage(message: string): string | null { + if (isActiveRunConflictError(message)) { + return "当前对话还在生成回答,请等待本轮回复结束,或先点“停止”再发送新的问题。"; + } + if (isModelConcurrencyLimitError(message)) { + return /当前账号对该模型/.test(message) + ? "当前账号对该模型的并发调用已达到上限,请等待已有回答结束后再试。" + : "当前使用人数较多,请稍等一下再试。"; + } + return null; +} + +function buildCurrentUserLabel(): string { const auth = getStoredAuth(); if (!auth) { return "未登录"; @@ -424,24 +428,24 @@ function lastHumanFromChatModelInput(input: unknown): string { if (Array.isArray(m)) m.forEach(push); else if (m != null) flat.push(m); }; - push(raw); - for (let i = flat.length - 1; i >= 0; i--) { - const m = flat[i]; - if (!m || typeof m !== "object") continue; - const obj = m as Record; - const type = obj.type ?? obj.role; - if (type === "human" || type === "user") { - const kwargs = obj.additional_kwargs; - if ( - kwargs && - typeof kwargs === "object" && - (kwargs as Record).hide_from_ui === true - ) { - continue; - } - const text = textFromChatModelOutput(m); - if (text) return text; - } + push(raw); + for (let i = flat.length - 1; i >= 0; i--) { + const m = flat[i]; + if (!m || typeof m !== "object") continue; + const obj = m as Record; + const type = obj.type ?? obj.role; + if (type === "human" || type === "user") { + const kwargs = obj.additional_kwargs; + if ( + kwargs && + typeof kwargs === "object" && + (kwargs as Record).hide_from_ui === true + ) { + continue; + } + const text = textFromChatModelOutput(m); + if (text) return text; + } } } catch { /* 取不到忽略 */ @@ -452,45 +456,45 @@ function lastHumanFromChatModelInput(input: unknown): string { export function useThreadStream({ threadId, context, - isMock, - assistantId, - runOptions, - threadMetadata, - onStart, - onFinish, - onToolStart, - onToolEnd, -}: ThreadStreamOptions) { + isMock, + assistantId, + runOptions, + threadMetadata, + onStart, + onFinish, + onToolStart, + onToolEnd, +}: ThreadStreamOptions) { const { t } = useI18n(); const [localSettings] = useLocalSettings(); // Track the thread ID that is currently streaming to handle thread changes during streaming const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); // Ref to track current thread ID across async callbacks without causing re-renders, // and to allow access to the current thread id in onUpdateEvent - const threadIdRef = useRef(threadId ?? null); - const startedRef = useRef(false); - const lastStreamActivityAtRef = useRef(0); - const streamReconciliationInFlightRef = useRef(false); - const streamReconnectAttemptsRef = useRef(0); - const streamReconnectFailureToastShownRef = useRef(false); - - const markStreamActivity = useCallback(() => { - lastStreamActivityAtRef.current = Date.now(); - streamReconnectAttemptsRef.current = 0; - streamReconnectFailureToastShownRef.current = false; - }, []); + const threadIdRef = useRef(threadId ?? null); + const startedRef = useRef(false); + const lastStreamActivityAtRef = useRef(0); + const streamReconciliationInFlightRef = useRef(false); + const streamReconnectAttemptsRef = useRef(0); + const streamReconnectFailureToastShownRef = useRef(false); - const listeners = useRef({ - onStart, - onFinish, - onToolStart, - onToolEnd, - }); + const markStreamActivity = useCallback(() => { + lastStreamActivityAtRef.current = Date.now(); + streamReconnectAttemptsRef.current = 0; + streamReconnectFailureToastShownRef.current = false; + }, []); + + const listeners = useRef({ + onStart, + onFinish, + onToolStart, + onToolEnd, + }); // Keep listeners ref updated with latest callbacks - useEffect(() => { - listeners.current = { onStart, onFinish, onToolStart, onToolEnd }; - }, [onStart, onFinish, onToolStart, onToolEnd]); + useEffect(() => { + listeners.current = { onStart, onFinish, onToolStart, onToolEnd }; + }, [onStart, onFinish, onToolStart, onToolEnd]); useEffect(() => { const normalizedThreadId = threadId ?? null; @@ -511,14 +515,14 @@ export function useThreadStream({ } }, []); - const handleStreamStart = useCallback( - (_threadId: string) => { - threadIdRef.current = _threadId; - markStreamActivity(); - _handleOnStart(_threadId); - }, - [_handleOnStart, markStreamActivity], - ); + const handleStreamStart = useCallback( + (_threadId: string) => { + threadIdRef.current = _threadId; + markStreamActivity(); + _handleOnStart(_threadId); + }, + [_handleOnStart, markStreamActivity], + ); const queryClient = useQueryClient(); const updateSubtask = useUpdateSubtask(); @@ -526,6 +530,9 @@ export function useThreadStream({ const runMetadataStorageRef = useRef< ReturnType | undefined >(undefined); + const joinStreamRef = useRef<(runId: string) => Promise>( + async () => undefined, + ); if ( typeof window !== "undefined" && @@ -542,8 +549,8 @@ export function useThreadStream({ ? () => runMetadataStorageRef.current! : false, fetchStateHistory: { limit: 1 }, - onCreated(meta) { - handleStreamStart(meta.thread_id); + onCreated(meta) { + handleStreamStart(meta.thread_id); setOnStreamThreadId(meta.thread_id); const metadataAgentId = context.agent_id ?? context.agent_name; const extraMetadata = @@ -557,9 +564,9 @@ export function useThreadStream({ .threads.update(meta.thread_id, { metadata }) .catch(() => ({})); } - }, - onLangChainEvent(event) { - markStreamActivity(); + }, + onLangChainEvent(event) { + markStreamActivity(); // 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条), // 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用 // 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。 @@ -581,35 +588,35 @@ export function useThreadStream({ } catch { /* 上报绝不影响问答 */ } - } - }, - // The SDK emits UI tool lifecycle events on the dedicated `tools` stream - // mode. `events` is a separate LangChain trace channel; using it for UI - // reactions can miss a tool that the conversation has already rendered. - onToolEvent: - onToolStart || onToolEnd - ? (event) => { - markStreamActivity(); - if (event.event === "on_tool_start") { - listeners.current.onToolStart?.({ - name: event.name, - data: event.input, - toolCallId: event.toolCallId, - }); - return; - } - if (event.event === "on_tool_end") { - listeners.current.onToolEnd?.({ - name: event.name, - data: event.output, - toolCallId: event.toolCallId, - }); - } - } - : undefined, - onUpdateEvent(data) { - markStreamActivity(); - const updates: Array | null> = Object.values( + } + }, + // The SDK emits UI tool lifecycle events on the dedicated `tools` stream + // mode. `events` is a separate LangChain trace channel; using it for UI + // reactions can miss a tool that the conversation has already rendered. + onToolEvent: + onToolStart || onToolEnd + ? (event) => { + markStreamActivity(); + if (event.event === "on_tool_start") { + listeners.current.onToolStart?.({ + name: event.name, + data: event.input, + toolCallId: event.toolCallId, + }); + return; + } + if (event.event === "on_tool_end") { + listeners.current.onToolEnd?.({ + name: event.name, + data: event.output, + toolCallId: event.toolCallId, + }); + } + } + : undefined, + onUpdateEvent(data) { + markStreamActivity(); + const updates: Array | null> = Object.values( data || {}, ); for (const update of updates) { @@ -630,10 +637,10 @@ export function useThreadStream({ ); } } - }, - onCustomEvent(event: unknown) { - markStreamActivity(); - if ( + }, + onCustomEvent(event: unknown) { + markStreamActivity(); + if ( typeof event === "object" && event !== null && "type" in event && @@ -648,20 +655,20 @@ export function useThreadStream({ return; } - if ( - typeof event === "object" && - event !== null && - "type" in event && - event.type === "llm_retry" && + if ( + typeof event === "object" && + event !== null && + "type" in event && + event.type === "llm_retry" && "message" in event && - typeof event.message === "string" && - event.message.trim() - ) { - // The backend is already handling this with bounded retry/backoff. - // Surfacing every transient model retry as a toast made normal model - // stalls look like repeated "network fluctuation" failures. - return; - } + typeof event.message === "string" && + event.message.trim() + ) { + // The backend is already handling this with bounded retry/backoff. + // Surfacing every transient model retry as a toast made normal model + // stalls look like repeated "network fluctuation" failures. + return; + } // Context compression about to run a (blocking) summarization LLM call — // surface a transient hint so the wait before the answer streams isn't a @@ -678,11 +685,35 @@ export function useThreadStream({ const e = event as { type: "context_compacting"; message: string }; toast(e.message); } - }, - onError(error) { - markStreamActivity(); - setOptimisticMessages([]); + }, + onError(error) { + markStreamActivity(); const message = getStreamErrorMessage(error); + // Replacing a stalled local SSE reader aborts the in-flight fetch. That + // is expected and must not look like a user-facing "network" failure. + if (isAbortLikeError(error)) { + return; + } + // A dropped SSE while the backend run is still alive: rejoin instead of + // toasting "Failed to fetch" / 网络波动 and leaving the answer half-done. + if (isTransientStreamTransportError(message)) { + const staleThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + const runId = staleThreadId + ? runMetadataStorageRef.current?.getItem(`lg:stream:${staleThreadId}`) + : null; + if ( + runId && + streamReconnectAttemptsRef.current < STREAM_RECONNECT_MAX_ATTEMPTS + ) { + streamReconnectAttemptsRef.current += 1; + lastStreamActivityAtRef.current = Date.now(); + void joinStreamRef.current(runId).catch(() => { + // Reconciliation will retry while the run stays non-terminal. + }); + return; + } + } + setOptimisticMessages([]); // A stale auto-reconnect ("Run not found") after a backend restart: // drop the dirty ``lg:stream:{threadId}`` key so switching back into this // conversation stops re-firing the same 404, and render from history @@ -731,29 +762,29 @@ export function useThreadStream({ } return; } - // 上一轮还没结束又发了新问题(409)或并发达到上限(429): - // 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。 - const friendly = friendlySendLimitMessage(message); - if (friendly) { - toast.info(friendly); - return; - } - toast.error(message); - }, - onFinish(state) { - markStreamActivity(); - listeners.current.onFinish?.(state.values); - void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); - }, + // 上一轮还没结束又发了新问题(409)或并发达到上限(429): + // 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。 + const friendly = friendlySendLimitMessage(message); + if (friendly) { + toast.info(friendly); + return; + } + toast.error(message); + }, + onFinish(state) { + markStreamActivity(); + listeners.current.onFinish?.(state.values); + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + }, }); // Optimistic messages shown before the server stream responds - const [optimisticMessages, setOptimisticMessages] = useState([]); - const [isUploading, setIsUploading] = useState(false); - const [threadValueOverrides, setThreadValueOverrides] = useState< - Partial - >({}); - const sendInFlightRef = useRef(false); + const [optimisticMessages, setOptimisticMessages] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [threadValueOverrides, setThreadValueOverrides] = useState< + Partial + >({}); + const sendInFlightRef = useRef(false); // Carries the ask time of an in-flight send until the server-assigned human // message id shows up, so we can bind the timestamp to the real id (see the // reconciliation effect below). ``known`` is the set of human ids that @@ -767,136 +798,147 @@ export function useThreadStream({ // Keep a ref to the latest `thread` so the threadId-change effect can stop // an in-flight stream without re-running on every render that produces a // new `thread` identity. - const threadStreamRef = useRef(thread); - threadStreamRef.current = thread; - - useEffect(() => { - const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null; - if (!activeThreadId || typeof window === "undefined") { - return; - } - - let disposed = false; - let refreshTimer: number | null = null; - - const reconcileRun = async () => { - if ( - disposed || - streamReconciliationInFlightRef.current || - Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS - ) { - return; - } - - const storage = runMetadataStorageRef.current; - const runId = storage?.getItem(`lg:stream:${activeThreadId}`); - if (!runId) return; - - streamReconciliationInFlightRef.current = true; - try { - const client = getAPIClient(isMock); - const run = await client.runs.get(activeThreadId, runId); - if (disposed) { - return; - } - - if (!TERMINAL_RUN_STATUSES.has(run.status)) { - // ``useStream`` retries one failed connection on its own. If that - // reader quietly stalls, however, its internal queue never gets a - // terminal error and the page remains frozen despite the backend - // continuing the run. Abort only the local reader, then join the - // same run so no work or tokens are lost server-side. - if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) { - return; - } - - if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) { - if (!streamReconnectFailureToastShownRef.current) { - streamReconnectFailureToastShownRef.current = true; - toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。"); - } - return; - } - - streamReconnectAttemptsRef.current += 1; - // Avoid enqueueing another reconnect before this new reader has had - // enough time to receive its first event. A genuine event resets - // the attempt counter through ``markStreamActivity``. - lastStreamActivityAtRef.current = Date.now(); - - const stream = threadStreamRef.current; - void Promise.resolve(stream.stop()) - .catch(() => { - // Stopping a stale local reader is best-effort; joining is still - // useful if the SDK already considers the stream stopped. - }) - .finally(() => { - if (disposed || threadIdRef.current !== activeThreadId) return; - void stream.joinStream(runId).catch(() => { - // The status poll will either reconcile a terminal run or - // schedule the next bounded reconnect attempt. - }); - }); - return; - } - - // The server is already terminal, so the existing SSE listener cannot - // receive any more useful events. Load the checkpoint directly before - // resetting the SDK stream; this preserves the normal onFinish side - // effects (e.g. marking a position delivery complete) even when the - // browser missed the terminal event or already reported a transport - // error and dropped ``isLoading``. - const finalState = await client.threads.getState(activeThreadId); - if (disposed) { - return; - } - - storage?.removeItem(`lg:stream:${activeThreadId}`); - streamReconnectAttemptsRef.current = 0; - streamReconnectFailureToastShownRef.current = false; - if (run.status === "success") { - setOptimisticMessages([]); - setThreadValueOverrides(finalState.values as Partial); - listeners.current.onFinish?.(finalState.values as AgentThreadState); - void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); - } else { - setThreadValueOverrides(finalState.values as Partial); - toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。"); - } - - // Changing the controlled thread id clears only the local SDK stream - // (it does not cancel the already-terminal backend run). Switching it - // back immediately makes useStream fetch the persisted messages, so - // the page exits the false loading state without a manual refresh. - setOnStreamThreadId(null); - refreshTimer = window.setTimeout(() => { - if (!disposed && threadIdRef.current === activeThreadId) { - setOnStreamThreadId(activeThreadId); - } - }, 0); - } catch { - // A transient status lookup failure must not disrupt a real stream; - // the next polling interval will try again if it is still quiet. - } finally { - if (!disposed) { - streamReconciliationInFlightRef.current = false; - } - } - }; - - const interval = window.setInterval( - () => void reconcileRun(), - STREAM_RECONCILIATION_POLL_MS, - ); - - return () => { - disposed = true; - window.clearInterval(interval); - if (refreshTimer !== null) { - window.clearTimeout(refreshTimer); - } - }; - }, [isMock, onStreamThreadId, queryClient, thread.isLoading]); + const threadStreamRef = useRef(thread); + threadStreamRef.current = thread; + joinStreamRef.current = (runId: string) => thread.joinStream(runId); + + useEffect(() => { + const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + if (!activeThreadId || typeof window === "undefined") { + return; + } + + let disposed = false; + let refreshTimer: number | null = null; + + const reconcileRun = async () => { + if ( + disposed || + streamReconciliationInFlightRef.current || + Date.now() - lastStreamActivityAtRef.current < STREAM_RECONCILIATION_IDLE_MS + ) { + return; + } + + const storage = runMetadataStorageRef.current; + const runId = storage?.getItem(`lg:stream:${activeThreadId}`); + if (!runId) return; + + streamReconciliationInFlightRef.current = true; + try { + const client = getAPIClient(isMock); + const run = await client.runs.get(activeThreadId, runId); + if (disposed) { + return; + } + + if (!TERMINAL_RUN_STATUSES.has(run.status)) { + // ``useStream`` retries one failed connection on its own. If that + // reader quietly stalls, however, its internal queue never gets a + // terminal error and the page remains frozen despite the backend + // continuing the run. + // + // SDK ``stop()`` is *not* a local-only abort: its onStop hook calls + // ``runs.cancel`` and deletes ``lg:stream:{threadId}``, which is + // exactly "答一半就不答了". Hide the run id first so stop() only + // aborts the browser reader, then join the still-running server run. + if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) { + return; + } + + if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) { + if (!streamReconnectFailureToastShownRef.current) { + streamReconnectFailureToastShownRef.current = true; + toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。"); + } + return; + } + + streamReconnectAttemptsRef.current += 1; + // Avoid enqueueing another reconnect before this new reader has had + // enough time to receive its first event. A genuine event resets + // the attempt counter through ``markStreamActivity``. + lastStreamActivityAtRef.current = Date.now(); + + const stream = threadStreamRef.current; + storage?.removeItem(`lg:stream:${activeThreadId}`); + void Promise.resolve(stream.stop()) + .catch(() => { + // Stopping a stale local reader is best-effort; joining is still + // useful if the SDK already considers the stream stopped. + }) + .finally(() => { + if (disposed || threadIdRef.current !== activeThreadId) return; + try { + storage?.setItem(`lg:stream:${activeThreadId}`, runId); + } catch { + /* noop */ + } + void stream.joinStream(runId).catch(() => { + // The status poll will either reconcile a terminal run or + // schedule the next bounded reconnect attempt. + }); + }); + return; + } + + // The server is already terminal, so the existing SSE listener cannot + // receive any more useful events. Load the checkpoint directly before + // resetting the SDK stream; this preserves the normal onFinish side + // effects (e.g. marking a position delivery complete) even when the + // browser missed the terminal event or already reported a transport + // error and dropped ``isLoading``. + const finalState = await client.threads.getState(activeThreadId); + if (disposed) { + return; + } + + storage?.removeItem(`lg:stream:${activeThreadId}`); + streamReconnectAttemptsRef.current = 0; + streamReconnectFailureToastShownRef.current = false; + if (run.status === "success") { + setOptimisticMessages([]); + setThreadValueOverrides(finalState.values as Partial); + listeners.current.onFinish?.(finalState.values as AgentThreadState); + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + } else { + setThreadValueOverrides(finalState.values as Partial); + toast.error("本轮生成未能完成,已同步当前对话内容,可重新发起请求。"); + } + + // Changing the controlled thread id clears only the local SDK stream + // (it does not cancel the already-terminal backend run). Switching it + // back immediately makes useStream fetch the persisted messages, so + // the page exits the false loading state without a manual refresh. + setOnStreamThreadId(null); + refreshTimer = window.setTimeout(() => { + if (!disposed && threadIdRef.current === activeThreadId) { + setOnStreamThreadId(activeThreadId); + } + }, 0); + } catch { + // A transient status lookup failure must not disrupt a real stream; + // the next polling interval will try again if it is still quiet. + } finally { + if (!disposed) { + streamReconciliationInFlightRef.current = false; + } + } + }; + + const interval = window.setInterval( + () => void reconcileRun(), + STREAM_RECONCILIATION_POLL_MS, + ); + + return () => { + disposed = true; + window.clearInterval(interval); + if (refreshTimer !== null) { + window.clearTimeout(refreshTimer); + } + }; + }, [isMock, onStreamThreadId, queryClient, thread.isLoading]); // Reset thread-local pending UI state when the caller actually switches AWAY // from a thread (threadId becomes nullish). This is the "click 新对话 / open @@ -909,13 +951,13 @@ export function useThreadStream({ // clearing optimistic messages in that case is exactly what made submitted // questions visually disappear — the live run that just started would be // aborted by the very effect intended to reset stale state. - useEffect(() => { - if (threadId != null) { - // Forward direction: starting or continuing with a real thread. - // No reset needed; the upstream useStream will load history for this id. - setThreadValueOverrides({}); - return; - } + useEffect(() => { + if (threadId != null) { + // Forward direction: starting or continuing with a real thread. + // No reset needed; the upstream useStream will load history for this id. + setThreadValueOverrides({}); + return; + } const prevThread = threadStreamRef.current; if (prevThread?.isLoading) { try { @@ -927,89 +969,89 @@ export function useThreadStream({ startedRef.current = false; sendInFlightRef.current = false; prevMsgCountRef.current = 0; - setOptimisticMessages([]); - setIsUploading(false); - setThreadValueOverrides({}); - }, [threadId]); - - useEffect(() => { - const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null; - const effectiveTitleProvisional = - (threadValueOverrides.title_provisional ?? - thread.values?.title_provisional) === true; - - if (!activeThreadId || thread.isLoading || !effectiveTitleProvisional) { - return; - } - - let cancelled = false; - let timeoutId: ReturnType | null = null; - const controller = new AbortController(); - - const poll = async () => { - while (!cancelled) { - try { - const nextState = await fetchThreadTitleRefreshState( - activeThreadId, - controller.signal, - ); - if (cancelled || !nextState) { - return; - } - - setThreadValueOverrides((prev) => ({ - ...prev, - title: nextState.title, - title_provisional: nextState.title_provisional, - })); - - if (nextState.title) { - updateCachedThreadLists(queryClient, (threads) => - threads.map((t) => - t.thread_id === activeThreadId - ? { - ...t, - values: { - ...t.values, - title: nextState.title, - title_provisional: nextState.title_provisional, - }, - } - : t, - ), - ); - } - - if (nextState.title_provisional !== true) { - void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); - return; - } - } catch { - return; - } - - await new Promise((resolve) => { - timeoutId = setTimeout(resolve, 2000); - }); - } - }; - - void poll(); - - return () => { - cancelled = true; - controller.abort(); - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [ - onStreamThreadId, - queryClient, - thread.isLoading, - thread.values?.title_provisional, - threadValueOverrides.title_provisional, - ]); + setOptimisticMessages([]); + setIsUploading(false); + setThreadValueOverrides({}); + }, [threadId]); + + useEffect(() => { + const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null; + const effectiveTitleProvisional = + (threadValueOverrides.title_provisional ?? + thread.values?.title_provisional) === true; + + if (!activeThreadId || thread.isLoading || !effectiveTitleProvisional) { + return; + } + + let cancelled = false; + let timeoutId: ReturnType | null = null; + const controller = new AbortController(); + + const poll = async () => { + while (!cancelled) { + try { + const nextState = await fetchThreadTitleRefreshState( + activeThreadId, + controller.signal, + ); + if (cancelled || !nextState) { + return; + } + + setThreadValueOverrides((prev) => ({ + ...prev, + title: nextState.title, + title_provisional: nextState.title_provisional, + })); + + if (nextState.title) { + updateCachedThreadLists(queryClient, (threads) => + threads.map((t) => + t.thread_id === activeThreadId + ? { + ...t, + values: { + ...t.values, + title: nextState.title, + title_provisional: nextState.title_provisional, + }, + } + : t, + ), + ); + } + + if (nextState.title_provisional !== true) { + void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + return; + } + } catch { + return; + } + + await new Promise((resolve) => { + timeoutId = setTimeout(resolve, 2000); + }); + } + }; + + void poll(); + + return () => { + cancelled = true; + controller.abort(); + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [ + onStreamThreadId, + queryClient, + thread.isLoading, + thread.values?.title_provisional, + threadValueOverrides.title_provisional, + ]); // Clear optimistic when server messages arrive (count increases) useEffect(() => { @@ -1196,71 +1238,71 @@ export function useThreadStream({ }), ); - const mergedContext = { - ...context, - ...extraContext, - }; - const selectedLlmWikiIds = Array.isArray( - mergedContext.llmwiki_knowledge_base_ids, - ) - ? mergedContext.llmwiki_knowledge_base_ids - : []; - const ragModeEnabled = - localSettings.display.ragModeEnabled || - isThreadRagModeLocked(threadId) || - selectedLlmWikiIds.length > 0; - if (ragModeEnabled) { - lockThreadRagMode(threadId); - } + const mergedContext = { + ...context, + ...extraContext, + }; + const selectedLlmWikiIds = Array.isArray( + mergedContext.llmwiki_knowledge_base_ids, + ) + ? mergedContext.llmwiki_knowledge_base_ids + : []; + const ragModeEnabled = + localSettings.display.ragModeEnabled || + isThreadRagModeLocked(threadId) || + selectedLlmWikiIds.length > 0; + if (ragModeEnabled) { + lockThreadRagMode(threadId); + } const contextCompressionEnabled = localSettings.display.contextCompressionEnabled ?? false; - const hiddenContextMessages = normalizeHiddenContextMessages(options); - const submittedMessages: Message[] = [ - ...hiddenContextMessages.map((item) => ({ - type: "human" as const, - ...(item.name ? { name: item.name } : {}), - content: [ - { - type: "text" as const, - text: item.text, - }, - ], - additional_kwargs: { - ...item.additionalKwargs, - hide_from_ui: true, - }, - })), - { - type: "human" as const, - content: [ - { - type: "text" as const, - text, - }, - ], - additional_kwargs: { - ...options?.additionalKwargs, - client_ts: clientTs, - ...(filesForSubmit.length > 0 - ? { files: filesForSubmit } - : {}), - }, - }, - ]; - - await thread.submit( - { - messages: submittedMessages, - }, - { - threadId: threadId, - streamSubgraphs: true, - streamResumable: true, - onDisconnect: runOptions?.onDisconnect, - multitaskStrategy: runOptions?.multitaskStrategy, - config: { + const hiddenContextMessages = normalizeHiddenContextMessages(options); + const submittedMessages: Message[] = [ + ...hiddenContextMessages.map((item) => ({ + type: "human" as const, + ...(item.name ? { name: item.name } : {}), + content: [ + { + type: "text" as const, + text: item.text, + }, + ], + additional_kwargs: { + ...item.additionalKwargs, + hide_from_ui: true, + }, + })), + { + type: "human" as const, + content: [ + { + type: "text" as const, + text, + }, + ], + additional_kwargs: { + ...options?.additionalKwargs, + client_ts: clientTs, + ...(filesForSubmit.length > 0 + ? { files: filesForSubmit } + : {}), + }, + }, + ]; + + await thread.submit( + { + messages: submittedMessages, + }, + { + threadId: threadId, + streamSubgraphs: true, + streamResumable: true, + onDisconnect: runOptions?.onDisconnect, + multitaskStrategy: runOptions?.multitaskStrategy, + config: { recursion_limit: 1000, }, context: { @@ -1287,59 +1329,59 @@ export function useThreadStream({ }, ); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - const friendly = friendlySendLimitMessage(message); - if (friendly) { - setOptimisticMessages((messages) => { - const human = messages.find((item) => item.type === "human"); - const kept = human ? [human] : []; - return [ - ...kept, - { - type: "ai", - id: `opt-ai-limit-${Date.now()}`, - content: friendly, - additional_kwargs: { element: "task" }, - } as Message, - ]; - }); - setIsUploading(false); - return; - } - setOptimisticMessages([]); - setIsUploading(false); - throw error; - } finally { + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const friendly = friendlySendLimitMessage(message); + if (friendly) { + setOptimisticMessages((messages) => { + const human = messages.find((item) => item.type === "human"); + const kept = human ? [human] : []; + return [ + ...kept, + { + type: "ai", + id: `opt-ai-limit-${Date.now()}`, + content: friendly, + additional_kwargs: { element: "task" }, + } as Message, + ]; + }); + setIsUploading(false); + return; + } + setOptimisticMessages([]); + setIsUploading(false); + throw error; + } finally { sendInFlightRef.current = false; } }, - [thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions], + [thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions], ); // Merge thread with optimistic messages for display - const mergedValues = - Object.keys(threadValueOverrides).length > 0 - ? { - ...thread.values, - ...threadValueOverrides, - } - : thread.values; - - const mergedMessages = - Array.isArray(mergedValues.messages) - ? (mergedValues.messages as Message[]) - : thread.messages; - - const mergedThread = - optimisticMessages.length > 0 || mergedValues !== thread.values - ? ({ - ...thread, - values: mergedValues, - messages: [...mergedMessages, ...optimisticMessages], - } as typeof thread) - : thread; + const mergedValues = + Object.keys(threadValueOverrides).length > 0 + ? { + ...thread.values, + ...threadValueOverrides, + } + : thread.values; + + const mergedMessages = + Array.isArray(mergedValues.messages) + ? (mergedValues.messages as Message[]) + : thread.messages; + + const mergedThread = + optimisticMessages.length > 0 || mergedValues !== thread.values + ? ({ + ...thread, + values: mergedValues, + messages: [...mergedMessages, ...optimisticMessages], + } as typeof thread) + : thread; // 第 4 个返回值 `onStreamThreadId` = 底层 useStream 当前实际绑定的线程 id(新建/未保存时为 null)。 // 调用方据此判断「流是否已切到全新线程」,避免在切换会话的那一帧把消息发进上一个会话 @@ -1595,8 +1637,8 @@ export function useDeleteThread() { * `MessageList`) so the removal shows immediately; a reload then reads the * persisted, already-trimmed state. */ -export function useDeleteMessage() { - return useMutation({ +export function useDeleteMessage() { + return useMutation({ mutationFn: async ({ threadId, messageId, @@ -1626,92 +1668,92 @@ export function useDeleteMessage() { // Fall back to the requested id so the caller can always hide something. return body.deleted_ids?.length ? body.deleted_ids : [messageId]; }, - }); -} - -/** - * Clear all messages from a thread checkpoint. The caller should immediately - * hide the returned ids locally because the active stream cache may still hold - * the old message objects until a reload/refetch reads the trimmed checkpoint. - */ -export function useClearThreadMessages() { - return useMutation({ - mutationFn: async ({ - threadId, - }: { - threadId: string; - }): Promise => { - const response = await apiFetch( - `${getBackendBaseURL()}/api/threads/${encodeURIComponent( - threadId, - )}/messages`, - { - method: "DELETE", - }, - ); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: "清空对话失败。" })); - throw new Error(error.detail ?? "清空对话失败。"); - } - - const body = (await response.json().catch(() => ({}))) as { - deleted_ids?: string[]; - }; - return body.deleted_ids ?? []; - }, - }); -} - -/** - * Persist an in-place message edit in the latest thread checkpoint. - * - * This is opt-in UI behaviour: most chat pages keep their existing - * "edit question and resubmit" affordance, while position roundtable can pass - * this mutation down to save the original message content itself. - */ -export function useEditMessage() { - return useMutation({ - mutationFn: async ({ - threadId, - messageId, - content, - }: { - threadId: string; - messageId: string; - content: string; - }): Promise => { - const response = await apiFetch( - `${getBackendBaseURL()}/api/threads/${encodeURIComponent( - threadId, - )}/messages/${encodeURIComponent(messageId)}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }, - ); - - if (!response.ok) { - const error = await response - .json() - .catch(() => ({ detail: "消息保存失败。" })); - throw new Error(error.detail ?? "消息保存失败。"); - } - - const body = (await response.json().catch(() => ({}))) as { - message?: Message; - }; - return body.message ?? ({ id: messageId, type: "human", content } as Message); - }, - }); -} - -/** - * Merge metadata into a conversation's record via the Gateway - * `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such + }); +} + +/** + * Clear all messages from a thread checkpoint. The caller should immediately + * hide the returned ids locally because the active stream cache may still hold + * the old message objects until a reload/refetch reads the trimmed checkpoint. + */ +export function useClearThreadMessages() { + return useMutation({ + mutationFn: async ({ + threadId, + }: { + threadId: string; + }): Promise => { + const response = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent( + threadId, + )}/messages`, + { + method: "DELETE", + }, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "清空对话失败。" })); + throw new Error(error.detail ?? "清空对话失败。"); + } + + const body = (await response.json().catch(() => ({}))) as { + deleted_ids?: string[]; + }; + return body.deleted_ids ?? []; + }, + }); +} + +/** + * Persist an in-place message edit in the latest thread checkpoint. + * + * This is opt-in UI behaviour: most chat pages keep their existing + * "edit question and resubmit" affordance, while position roundtable can pass + * this mutation down to save the original message content itself. + */ +export function useEditMessage() { + return useMutation({ + mutationFn: async ({ + threadId, + messageId, + content, + }: { + threadId: string; + messageId: string; + content: string; + }): Promise => { + const response = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent( + threadId, + )}/messages/${encodeURIComponent(messageId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ detail: "消息保存失败。" })); + throw new Error(error.detail ?? "消息保存失败。"); + } + + const body = (await response.json().catch(() => ({}))) as { + message?: Message; + }; + return body.message ?? ({ id: messageId, type: "human", content } as Message); + }, + }); +} + +/** + * Merge metadata into a conversation's record via the Gateway + * `PATCH /api/threads/{id}` route. Used to persist per-conversation flags such * as `is_pinned` (置顶) and `is_favorite` (收藏) — a conversation belongs to a * single user, so these live directly on the thread metadata (no join table). * Updates the `["threads", "search"]` cache optimistically so the UI reflects @@ -1787,29 +1829,29 @@ export function useRenameThread() { mutationFn: async ({ threadId, title, - }: { - threadId: string; - title: string; - }) => { - await apiClient.threads.updateState(threadId, { - values: { title, title_provisional: false }, - }); - }, - onSuccess(_, { threadId, title }) { - updateCachedThreadLists(queryClient, (threads) => - threads.map((t) => + }: { + threadId: string; + title: string; + }) => { + await apiClient.threads.updateState(threadId, { + values: { title, title_provisional: false }, + }); + }, + onSuccess(_, { threadId, title }) { + updateCachedThreadLists(queryClient, (threads) => + threads.map((t) => t.thread_id === threadId ? { ...t, - values: { - ...t.values, - title, - title_provisional: false, - }, - } - : t, - ), - ); + values: { + ...t.values, + title, + title_provisional: false, + }, + } + : t, + ), + ); }, }); } diff --git a/frontend-web/src/core/threads/thread-access-error.ts b/frontend-web/src/core/threads/thread-access-error.ts index dd803081..4b01f74f 100644 --- a/frontend-web/src/core/threads/thread-access-error.ts +++ b/frontend-web/src/core/threads/thread-access-error.ts @@ -62,6 +62,37 @@ export function isThreadNotAccessibleError(message: string): boolean { ); } +export function isAbortLikeError(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + if (error instanceof Error && error.name === "AbortError") { + return true; + } + const message = getStreamErrorMessage(error).toLowerCase(); + return ( + message.includes("aborterror") || + message.includes("the operation was aborted") || + message.includes("bodystreambuffer") + ); +} + +export function isTransientStreamTransportError(message: string): boolean { + const lowered = message.toLowerCase(); + return ( + /failed to fetch/i.test(message) || + lowered.includes("networkerror") || + lowered.includes("network error") || + lowered.includes("load failed") || + lowered.includes("err_connection") || + lowered.includes("err_incomplete") || + lowered.includes("err_network") || + lowered.includes("econnreset") || + lowered.includes("econnaborted") || + /fetch failed/i.test(message) + ); +} + /** Build a label for the *current* logged-in identity (email → user_id → 未知). */ export function buildCurrentUserLabel(): string { const auth = getStoredAuth(); diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx index 3ca451a2..2af55a28 100644 --- a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx +++ b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx @@ -167,14 +167,27 @@ export function EightBfStep1Sidebar({ taskCard, businessChain, intentReady, + onCollapse, }: { taskCard?: ReactNode; businessChain?: ReactNode; intentReady: object | null; + onCollapse?: () => void; }) { return (
{taskCard} + {onCollapse && !taskCard && ( + + )}
); diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index ed99b6e3..870fa65e 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -267,6 +267,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa newTaskLabel="新建会话" hideDefaultStep1RightSidebar step2HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 多智能体会商`} + step3HeaderTitle={`${configuredChain?.title || "8BF 业务链"} · 方案总结`} renderStep1={(props) => ( )} - renderStep1LeftSidebar={({ intentReady }) => ( + renderStep1LeftSidebar={({ intentReady, onCollapse, submittedSeatKeys, onSeatClick }) => ( ) : undefined} businessChain={ @@ -291,6 +294,8 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa chain={configuredChain} loading={isLoading} embedded + submittedSeatKeys={submittedSeatKeys} + onSeatClick={onSeatClick} className="position-roundtable-intent-delivery-flow eightbf-roundtable__business-chain-flow" /> } diff --git a/frontend-web/src/eightbf-roundtable/styles/step3.css b/frontend-web/src/eightbf-roundtable/styles/step3.css index 35936ad7..9e0ab901 100644 --- a/frontend-web/src/eightbf-roundtable/styles/step3.css +++ b/frontend-web/src/eightbf-roundtable/styles/step3.css @@ -76,22 +76,70 @@ background: #21958a; } -/* 第三步只保留「总结报告 + 沙箱」。沿用第一、二步的 4px 面板语言。 */ -.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-source-panel { - border: 1px solid rgb(43 143 254 / 50%); - border-radius: 4px; - background: #032650; - padding: 12px; +/* 第三步:8BF 左栏与第一步相同,对话区去掉原外层圆角白框,沿用第一、二步深蓝面板。 */ +.dark .eightbf-roundtable[data-roundtable-step="3"] > main { + padding: 12px 0; } -.dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable__step3-report-panel { +.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="true"] { + gap: 0; +} + +.dark .eightbf-roundtable[data-roundtable-step="3"] > main[data-step2-sidebar-expanded="false"] { + gap: 10px; +} + +.dark .eightbf-roundtable[data-roundtable-step="3"] #middle-viewport { border-color: rgb(43 143 254 / 50%); - border-radius: 4px !important; + border-radius: 4px; background: #032650; box-shadow: none; } +.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { + width: 374px; + padding: 0 12px; +} + +.dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="false"] { + width: 80px; + padding: 0 12px; +} + +.eightbf-roundtable .eightbf-roundtable-step3-chat { + min-height: 0; + background: #032650; +} + +.eightbf-roundtable .eightbf-roundtable-step3-log { + background: #032650; + border: 0; + box-shadow: none; +} + +.eightbf-roundtable .eightbf-roundtable-step3-chat .position-roundtable-intent-header-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-700, +.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-500 { + color: #aaceff; +} + +.eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-log .text-slate-400 { + color: #8aa0c4; +} + /* PositionIntentInput is the shared Step 1/2 composer; Step 3 uses the exact same frame. */ .dark .eightbf-roundtable[data-roundtable-step="3"] .eightbf-roundtable-step3-input { flex: 0 0 auto; } + +@media (max-width: 1280px) { + .dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; } +} + +@media (max-width: 1024px) { + .dark .eightbf-roundtable[data-roundtable-step="3"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; } +} diff --git a/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx b/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx index d3d85eff..5b65efbd 100644 --- a/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx +++ b/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx @@ -5,7 +5,7 @@ * 不会读取或写入岗位会商的会话、岗位节点状态。 */ import { useRef, useState } from "react"; -import { CheckCircle2, Circle, Search } from "lucide-react"; +import { Check, Search } from "lucide-react"; import { Dialog, @@ -34,6 +34,14 @@ export interface PositionBusinessChainFlowProps { * border and background; this flow keeps only its title row and canvas. */ embedded?: boolean; + /** + * Identity keys of seats whose Markdown artifact has been submitted. + * Matched against `seat.agent_id` and `seat.name` (case-insensitive). + * Green filled check = submitted only, same as 岗位会商 `is-delivered`. + */ + submittedSeatKeys?: readonly string[]; + /** Click a seat node. Host opens the deliverable dialog when a submission exists. */ + onSeatClick?: (seat: ChainSeat) => void; } function getFlowStages(chain: BusinessChain): FlowStage[] { @@ -51,13 +59,27 @@ function getFlowStages(chain: BusinessChain): FlowStage[] { return chain.seats.map((seat) => ({ goal: null, seats: [seat] })); } +function isSeatSubmitted(seat: ChainSeat, submittedSeatKeys: ReadonlySet): boolean { + return submittedSeatKeys.has(seat.agent_id.trim().toLowerCase()) + || submittedSeatKeys.has((seat.name ?? "").trim().toLowerCase()); +} + export function PositionBusinessChainFlow({ chain, loading = false, className, embedded = false, + submittedSeatKeys, + onSeatClick, }: PositionBusinessChainFlowProps) { const [dialogOpen, setDialogOpen] = useState(false); + const submittedKeys = new Set( + (submittedSeatKeys ?? []).map((key) => key.trim().toLowerCase()).filter(Boolean), + ); + const handleSeatClick = (seat: ChainSeat) => { + onSeatClick?.(seat); + setDialogOpen(false); + }; return (
) : ( <> - + {chain.title} · 业务链条流程图
- +
@@ -133,11 +167,15 @@ function BusinessChainCanvas({ chain, expanded = false, embedded = false, + submittedKeys, + onSeatClick, className, }: { chain: BusinessChain; expanded?: boolean; embedded?: boolean; + submittedKeys: ReadonlySet; + onSeatClick?: (seat: ChainSeat) => void; className?: string; }) { const [view, setView] = useState({ tx: 0, ty: 0 }); @@ -175,6 +213,7 @@ function BusinessChainCanvas({ onDoubleClick={() => setView({ tx: 0, ty: 0 })} onPointerDown={(event) => { if (event.button !== 0) return; + if ((event.target as HTMLElement | null)?.closest?.(".pr-flow-card")) return; dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty }; setPanning(true); event.currentTarget.setPointerCapture(event.pointerId); @@ -217,6 +256,8 @@ function BusinessChainCanvas({ accent={accent} width={nodeWidth} compact={embedded} + completed={isSeatSubmitted(seat, submittedKeys)} + onClick={onSeatClick ? () => onSeatClick(seat) : undefined} /> ); })} @@ -277,6 +318,7 @@ function FlowNode({ completed = false, width, compact = false, + onClick, }: { title: string; description: string; @@ -284,25 +326,27 @@ function FlowNode({ completed?: boolean; width: number; compact?: boolean; + onClick?: () => void; }) { - const StatusIcon = completed ? CheckCircle2 : Circle; - return ( -
+ const className = cn( + "pr-flow-card relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 text-left font-[inherit] shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]", + compact && "rounded-lg", + completed && "border-[#36b987]/60", + onClick && "cursor-pointer transition hover:-translate-y-0.5 hover:shadow-md", + ); + const style = { width, borderLeftWidth: 5, borderLeftColor: accent }; + const body = ( + <> - + {completed ? : null}

{title} @@ -310,6 +354,25 @@ function FlowNode({

{description}

+ + ); + if (onClick) { + return ( + + ); + } + return ( +
+ {body}
); } diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 79d9fc76..34d98be8 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -59,6 +59,8 @@ interface SeatDelivery { } interface HighFidelityReportProps { + useStep1Chrome?: boolean; + headerTitle?: string; threadIds: ThreadIdMap | null; coordinatorName: string | null; /** The most recent leader-agent text — rendered as the final consensus. */ @@ -120,6 +122,8 @@ function formatGeneratedAt(d: Date): string { } export const HighFidelityReport: React.FC = ({ + useStep1Chrome = false, + headerTitle, threadIds, coordinatorName, consensusText, @@ -433,6 +437,8 @@ export const HighFidelityReport: React.FC = ({ if (summaryMode) { return ( - {/* 顶栏 */} -
-
- -

方案总结 · 报告

- - {(phase === "creating" || phase === "streaming") && ( - - )} - {phase === "done" && } - {statusLabel} - + {/* 顶栏。8BF 改走对话区内的第一步同款顶栏,这里不再套一层。 */} + {!useStep1Chrome && ( +
+
+ +

方案总结 · 报告

+ + {(phase === "creating" || phase === "streaming") && ( + + )} + {phase === "done" && } + {statusLabel} + +
+
+ +
-
- -
-
+ )} - {/* 主体:左源卡 + 右沙箱 */} -
+ {/* 主体:左源卡 + 右沙箱。8BF 左栏改用第一步任务/意图区,这里只留对话面板。 */} +
+ {!useStep1Chrome && ( + )} {/* 右栏:智能体进度 + Markdown 沙箱(复用第 2 步)。 */}
-
-
-
- 报 +
+ {useStep1Chrome ? ( +
+
+
-
-
方案总结报告智能体
-
据各席位成果撰写 Markdown 总结报告
+
+
-
+ ) : ( +
+
+
+ 报 +
+
+
方案总结报告智能体
+
据各席位成果撰写 Markdown 总结报告
+
+
+
+ )} {/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
-
+
{phase === "creating" && dialogues.length === 0 && (
diff --git a/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts b/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts index 7dfbca9d..ce413a3c 100644 --- a/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts +++ b/frontend-web/src/roundtable-planning/hooks/useTaskArtifactSubmission.ts @@ -193,6 +193,13 @@ export function useTaskArtifactSubmission( return operation; }, [config, resolveTarget]); + const submittedRecords = useMemo( + () => Object.values(entries) + .filter((entry) => entry.state === "submitted" && entry.record?.submitted) + .map((entry) => entry.record as RoundtableArtifactSubmissionRecord), + [entries], + ); + return useMemo(() => { if (!config) return undefined; return { @@ -202,6 +209,7 @@ export function useTaskArtifactSubmission( stateFor: (target) => entries[recordKey(resolveTarget(target))]?.state || "idle", errorFor: (target) => entries[recordKey(resolveTarget(target))]?.error || null, submit, + submittedRecords, } satisfies TaskArtifactSubmissionController; - }, [config, entries, resolveTarget, submit]); + }, [config, entries, resolveTarget, submit, submittedRecords]); } diff --git a/frontend-web/src/roundtable-planning/lib/match-seat-submission.ts b/frontend-web/src/roundtable-planning/lib/match-seat-submission.ts new file mode 100644 index 00000000..297a4f5d --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/match-seat-submission.ts @@ -0,0 +1,92 @@ +import type { RoundtableArtifactSubmissionRecord } from "@/roundtable-planning/api/artifact-submissions"; +import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; +import type { ChainSeat } from "@/roundtable-planning/lib/business-chain"; +import { getDisplayNameFor, type SelectedAgent } from "@/roundtable-planning/lib/roundtable-constants"; + +function norm(value: string | null | undefined): string { + return (value ?? "").trim().toLowerCase(); +} + +function sourceKeyOf(record: RoundtableArtifactSubmissionRecord): string { + const raw = (record.artifact.sourceKey ?? "").trim(); + if (!raw || raw.toLowerCase().startsWith("thread:")) return ""; + return raw; +} + +/** + * Match a chain seat to a submitted Markdown artifact. + * + * Submissions are keyed by the graph agent name (`threadIds` key) or UUID; + * chain seats expose both `agent_id` and display `name`. + */ +export function findSeatSubmission( + seat: ChainSeat, + records: RoundtableArtifactSubmissionRecord[], + selectedAgents: SelectedAgent[], + threadIds: ThreadIdMap | null, +): RoundtableArtifactSubmissionRecord | null { + const selected = selectedAgents.find((agent) => agent.agent_id === seat.agent_id); + const aliases = new Set( + [ + seat.agent_id, + seat.name, + selected?.agent_id, + selected?.name, + selected ? getDisplayNameFor(selected) : "", + ] + .map(norm) + .filter(Boolean), + ); + + const hits = records.filter((record) => { + if (!record.submitted) return false; + const sourceKey = norm(sourceKeyOf(record)); + if (sourceKey && aliases.has(sourceKey)) return true; + if (!threadIds) return false; + const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0]; + if (owner && aliases.has(norm(owner))) return true; + for (const alias of aliases) { + const mapped = threadIds[alias]; + if (mapped && mapped === record.artifact.threadId) return true; + } + const byName = threadIds[seat.name] ?? (selected ? threadIds[selected.name] : undefined); + return Boolean(byName && byName === record.artifact.threadId); + }); + + if (hits.length === 0) return null; + return hits + .slice() + .sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? ""))[0] ?? null; +} + +/** Identity keys the flowchart can match against `seat.agent_id` / `seat.name`. */ +export function submittedSeatKeysFor( + seats: ChainSeat[], + records: RoundtableArtifactSubmissionRecord[], + selectedAgents: SelectedAgent[], + threadIds: ThreadIdMap | null, +): string[] { + const keys = new Set(); + for (const record of records) { + if (!record.submitted) continue; + const sourceKey = sourceKeyOf(record); + if (sourceKey) keys.add(norm(sourceKey)); + if (threadIds) { + const owner = Object.entries(threadIds).find(([, id]) => id === record.artifact.threadId)?.[0]; + if (owner) keys.add(norm(owner)); + } + } + for (const seat of seats) { + if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue; + keys.add(norm(seat.agent_id)); + if (seat.name) keys.add(norm(seat.name)); + } + for (const agent of selectedAgents) { + const seat = { agent_id: agent.agent_id, name: agent.name }; + if (!findSeatSubmission(seat, records, selectedAgents, threadIds)) continue; + keys.add(norm(agent.agent_id)); + keys.add(norm(agent.name)); + keys.add(norm(getDisplayNameFor(agent))); + } + return [...keys]; +} diff --git a/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts b/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts index d9a1d5e1..de257493 100644 --- a/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts +++ b/frontend-web/src/roundtable-planning/lib/task-artifact-submission.ts @@ -37,4 +37,6 @@ export interface TaskArtifactSubmissionController { stateFor: (target: TaskArtifactTarget) => TaskArtifactSubmissionState; errorFor: (target: TaskArtifactTarget) => string | null; submit: (target: TaskArtifactTarget) => Promise; + /** Task-scoped ledger rows that have been formally submitted. */ + submittedRecords: RoundtableArtifactSubmissionRecord[]; } diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 7a03cbc6..e3ea2bfe 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -135,6 +135,7 @@ import { import { buildChainSeatSelection, type BusinessChain, + type ChainSeat, type SeatSelectionResult, } from "@/roundtable-planning/lib/business-chain"; import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode"; @@ -156,6 +157,14 @@ import { emitWujieEvent, isWujieEnv, onWujieEvent, readWujieProps } from "@/core import { collectRoundtableDeliverables } from "@/roundtable-planning/lib/roundtable-deliverables"; import { useTaskArtifactSubmission } from "@/roundtable-planning/hooks/useTaskArtifactSubmission"; import type { TaskArtifactSubmissionConfig } from "@/roundtable-planning/lib/task-artifact-submission"; +import { + findSeatSubmission, + submittedSeatKeysFor, +} from "@/roundtable-planning/lib/match-seat-submission"; +import { + AnalysisResultDetailDialog, + type AnalysisResultCardItem, +} from "@/components/analysis-results"; /** * 业务码 → 任务工作区业务码(``TaskBusiness``),用于深链 taskId 模式底部「快捷跳转」栏 @@ -320,6 +329,7 @@ export default function RoundtablePlanningPage({ hideDefaultStep1RightSidebar = false, step2HeaderTitle, step2HeaderRightSlot, + step3HeaderTitle, /** 独立业务页的工作区品牌区,取代通用步骤导航。 */ headerLeftSlot, /** 放在历史记录后面的独立业务操作或状态。 */ @@ -353,7 +363,12 @@ export default function RoundtablePlanningPage({ step2SeatStubMessages: Record; step3Summary: DraftStep3SummarySnapshot | null; }) => ReactNode; - renderStep1LeftSidebar?: (context: { intentReady: object | null }) => ReactNode; + renderStep1LeftSidebar?: (context: { + intentReady: object | null; + onCollapse?: () => void; + submittedSeatKeys?: string[]; + onSeatClick?: (seat: ChainSeat) => void; + }) => ReactNode; renderStep2LeftSidebarHeader?: (context: { intentReady: object | null; onCollapse: () => void; @@ -363,6 +378,8 @@ export default function RoundtablePlanningPage({ step2HeaderTitle?: string; /** 8BF 第二步顶栏右侧附加操作(如返回)。 */ step2HeaderRightSlot?: ReactNode; + /** 8BF 第三步对话区标题,与第一、二步顶栏同一套样式。 */ + step3HeaderTitle?: string; headerLeftSlot?: ReactNode; headerActionAfterHistory?: ReactNode; hideHeaderSaveDraft?: boolean; @@ -482,11 +499,11 @@ export default function RoundtablePlanningPage({ consensusEmittedRef.current = true; } }, [inWujieEmbed]); - // 带 taskId 时,先按任务回显最新草稿再决定是否 autoStart。用 state 而非 ref: + // 带存储键 / 深链 taskId 时,先回显最新草稿再决定是否 autoStart。用 state 而非 ref: // 解析完成需触发重渲染让 C2 effect 重跑(否则 intentThreadId 先就绪会错过 autoStart)。 - // 无 taskId 时初始即「已解析」,autoStart 照常。 + // 无存储键时初始即「已解析」,autoStart 照常。8BF 走 storageTaskId,不依赖 prefill.taskId。 const [taskRestoreResolved, setTaskRestoreResolved] = useState( - !prefillRef.current?.taskId, + !draftStorageTaskId, ); // 深链/预填自动会话:进入页面后要先建意图线程、(带 taskId 时)回显历史草稿,这段时间 // 页面只有一句欢迎语、问答还没真正开始,线程创建慢时像卡住。下面这个标志用来在超时后 @@ -742,6 +759,58 @@ export default function RoundtablePlanningPage({ step2.agentNameToRole, ); + const [seatDeliverable, setSeatDeliverable] = useState(null); + const submittedRecords = taskArtifactSubmission?.submittedRecords; + const submittedSeatKeys = useMemo( + () => submittedSeatKeysFor( + step2.selectedAgents.map((agent) => ({ agent_id: agent.agent_id, name: agent.name })), + submittedRecords ?? [], + step2.selectedAgents, + step2.threadIds, + ), + [submittedRecords, step2.selectedAgents, step2.threadIds], + ); + const handleChainSeatClick = useCallback((seat: ChainSeat) => { + const record = findSeatSubmission( + seat, + submittedRecords ?? [], + step2.selectedAgents, + step2.threadIds, + ); + if (!record) { + triggerToast("该节点暂无可预览产物"); + return; + } + setSeatDeliverable({ + id: record.id, + agentName: seat.name || record.artifact.sourceKey, + productName: record.artifact.name, + positionName: seat.description?.trim() || autoChainResult?.chain?.title || "业务链席位", + delivered: true, + artifactPath: record.artifact.path, + artifactMimeType: record.artifact.mimeType, + artifactThreadId: record.artifact.threadId, + updatedAt: record.submittedAt ?? undefined, + }); + }, [ + autoChainResult?.chain?.title, + step2.selectedAgents, + step2.threadIds, + submittedRecords, + triggerToast, + ]); + const renderStep1LeftSidebarWithDeliveries = useMemo( + () => (renderStep1LeftSidebar + ? (context: { intentReady: object | null; onCollapse?: () => void }) => + renderStep1LeftSidebar({ + ...context, + submittedSeatKeys, + onSeatClick: taskArtifactSubmission ? handleChainSeatClick : undefined, + }) + : undefined), + [handleChainSeatClick, renderStep1LeftSidebar, submittedSeatKeys, taskArtifactSubmission], + ); + // 同步 step1StateRef,让 buildInitialIntent / Persona / Step 3 都能读到最新值。 useEffect(() => { step1StateRef.current = { @@ -1287,11 +1356,11 @@ export default function RoundtablePlanningPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // C1.5(无界回显):带 taskId 时,挂载即按任务取最新草稿回显。命中 → 抑制 autoStart + // C1.5(无界 / 8BF 回显):带存储键时,挂载即取最新草稿回显。命中 → 抑制 autoStart // (避免重复发首条消息);未命中 → 放行 autoStart 走全新任务。只跑一次。 useEffect(() => { - // 回显聊天记录按存储键(xdfx-A=行动id),与 useDraftPersistence 的 taskId 保持一致。 - const tid = recordTaskIdOf(prefillRef.current); + // 回显聊天记录按存储键:8BF 用独立 `storageTaskId`;xdfx-A 按行动id;其它深链按真实 taskId。 + const tid = draftStorageTaskId; if (!tid) return; let cancelled = false; void (async () => { @@ -2218,6 +2287,15 @@ export default function RoundtablePlanningPage({ }} /> + {taskArtifactSubmission && ( + { + if (!open) setSeatDeliverable(null); + }} + /> + )} + {showToast && (
@@ -2371,7 +2449,9 @@ export default function RoundtablePlanningPage({ setSidebarExpanded={setStep2SidebarExpanded} />
{/* LEFT SIDEBAR */} @@ -2399,7 +2479,7 @@ export default function RoundtablePlanningPage({ lockBusinessChain={lockBusinessChain} entryRailBusinessChainSlot={entryRailBusinessChainSlot} entryRailTaskCardSlot={entryRailTaskCardSlot} - renderStep1LeftSidebar={renderStep1LeftSidebar} + renderStep1LeftSidebar={renderStep1LeftSidebarWithDeliveries} renderStep2LeftSidebarHeader={renderStep2LeftSidebarHeader} /> @@ -2496,6 +2576,8 @@ export default function RoundtablePlanningPage({ {currentStep === 3 && ( ReactNode; + renderStep1LeftSidebar?: (context: { + intentReady: object | null; + onCollapse?: () => void; + submittedSeatKeys?: string[]; + onSeatClick?: (seat: ChainSeat) => void; + }) => ReactNode; /** 独立业务页在第二步左栏顶部复用任务与意图区。 */ renderStep2LeftSidebarHeader?: (context: { intentReady: object | null; @@ -2952,13 +3039,21 @@ function Step2SandboxSidebarSync({ }) { const { open } = useArtifacts(); const prevOpenRef = useRef(open); + const prevStepRef = useRef(currentStep); useEffect(() => { - if (currentStep !== 2) { + const enteredCollapsibleStep = + (currentStep === 2 || currentStep === 3) && + prevStepRef.current !== currentStep; + prevStepRef.current = currentStep; + + if (currentStep !== 2 && currentStep !== 3) { prevOpenRef.current = open; return; } - if (open && !prevOpenRef.current && sidebarExpanded) { + const openedNow = open && !prevOpenRef.current; + const landedWithSandbox = enteredCollapsibleStep && open; + if ((openedNow || landedWithSandbox) && sidebarExpanded) { setSidebarExpanded(false); } prevOpenRef.current = open; @@ -3002,9 +3097,38 @@ function LeftSidebar({ const toggleSeatFilter = (filter: SeatFilter) => setSeatFilter((cur) => (cur === filter ? null : filter)); - // Step 3 隐藏左侧栏(原「参与与结论贡献」列),让中间「最终方案展示」自适应 - // 撑满整行宽度(右侧栏在 Step !==1 时也已 return null)。 - if (currentStep === 3) return null; + // Step 3:通用会商隐藏左栏以让方案展示撑满;8BF 复用第一步左栏,并和第二步一样可收起。 + if (currentStep === 3) { + if (!renderStep1LeftSidebar) return null; + return ( + + ); + } return (
str: + """Normalize an artifacts URL path to a sandbox virtual path. + + Streaming write previews may request a bare filename such as + ``未命名文件.md`` before the model has emitted a real + ``/mnt/user-data/outputs/...`` path. Mapping a single path segment into + the thread outputs directory turns that into a normal 404 (file not written + yet) instead of HTTP 400 ``Path must start with /mnt/user-data``. + """ + stripped = path.strip().lstrip("/").replace("\\", "/") + if not stripped: + raise HTTPException(status_code=400, detail="Path must start with /mnt/user-data") + if stripped == _VIRTUAL_USER_DATA_PREFIX or stripped.startswith(_VIRTUAL_USER_DATA_PREFIX + "/"): + return stripped + if "/" in stripped or stripped in {".", ".."}: + raise HTTPException(status_code=400, detail="Path must start with /mnt/user-data") + return f"{_VIRTUAL_USER_DATA_PREFIX}/outputs/{stripped}" + class ArtifactUpdateRequest(BaseModel): content: str @@ -198,6 +219,7 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo - Download file: `/api/threads/abc123/artifacts/mnt/user-data/outputs/data.csv?download=true` - Active web content such as `.html`, `.xhtml`, and `.svg` artifacts is always downloaded """ + path = coerce_artifact_virtual_path(path) # Check if this is a request for a file inside a .skill archive (e.g., xxx.skill/SKILL.md) if ".skill/" in path: # Split the path at ".skill/" to get the ZIP file path and internal path @@ -285,6 +307,7 @@ async def update_artifact( if ".skill/" in path: raise HTTPException(status_code=400, detail="Skill archive contents are read-only.") + path = coerce_artifact_virtual_path(path) actual_path = await aresolve_thread_virtual_path(thread_id, path) logger.info(f"Updating artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")