diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index 1d980f6a..fd25317d 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -346,15 +346,22 @@ export function AgentGallery() { 请输入访问密码,验证通过后才能查看其中的智能体。

-
+
{ + event.preventDefault(); + handleSubmitPassword(); + }} + > + setPasswordInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleSubmitPassword(); - }} // This is a one-off access code, not a login credential. Tell // browsers / password managers not to save or autofill it (which // would also stop them from spilling a saved account into the @@ -365,8 +372,8 @@ export function AgentGallery() { data-lpignore="true" data-form-type="other" /> - -
+ + ); } diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index 742eeba2..b1bcd45e 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -14,11 +14,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useTheme, type ThemeType } from "@/contexts/ThemeContext"; +import type { CompactShortcutRouteParam } from "@/core/system-settings/api"; import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks"; import { isHideChromeActive } from "@/core/embed/embed-session"; import { env } from "@/env"; import { cn } from "@/lib/utils"; -import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; +import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app"; /** * 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用, @@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) => ? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30" : "text-muted-foreground hover:bg-muted hover:text-foreground"; +function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) { + const pathSegments = params + .filter((param) => param.paramType === "path") + .map((param) => + resolveRouteParamValue({ ...param, paramType: "login" }).trim(), + ) + .filter(Boolean) + .map((value) => encodeURIComponent(value)); + if (pathSegments.length === 0) return url; + + const hashIndex = url.indexOf("#"); + if (hashIndex < 0) { + const [path, query = ""] = url.split("?", 2); + return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`; + } + + const beforeHash = url.slice(0, hashIndex); + const fragment = url.slice(hashIndex + 1); + const [routePath, query = ""] = fragment.split("?", 2); + return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`; +} + +function isQueryShortcutParam( + param: CompactShortcutRouteParam, +): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } { + return param.paramType === "login" || param.paramType === "theme"; +} + export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) { const { data: appearance } = useAppearanceSettings(); const buttons = (appearance?.compact_shortcut_buttons ?? []) @@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone } )} title={button.label} onClick={() => { + const routeParams = (button.route_params ?? []).map((param) => ({ ...param })); const url = buildLightAppUrl({ - url: button.url, - routeParams: button.route_params.map((param) => ({ ...param })), + url: appendShortcutPathParams(button.url, routeParams), + routeParams: routeParams.filter(isQueryShortcutParam), }); window.open(url, "_blank", "noopener,noreferrer"); }} 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 a938bb46..c8170fd9 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -43,7 +43,11 @@ 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 { + useCapabilities as useResearchCapabilities, + useResearchSources, + type DeepResearchSource, +} from "@/strategy-components/api/deep-research"; import { listDurableDocumentRewrites, streamDurableDocumentRewriteJob, @@ -54,10 +58,17 @@ 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 { + DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, + DOCUMENT_REWRITE_STARTED_EVENT, + DOCUMENT_REWRITE_UNDONE_EVENT, + useDocumentRewritePresentation, +} from "@/core/document-rewrite/context"; +import { + clearArtifactRewriteFallback, + completeArtifactRewriteFallback, + primeArtifactRewriteFallback, +} from "@/core/document-rewrite/local-fallback"; import { downloadArtifactFile, } from "@/core/artifacts/download"; @@ -100,7 +111,34 @@ import { useArtifacts } from "./context"; import { SpreadsheetPreview } from "./spreadsheet-preview"; import { SkillInstallDialog } from "./skill-install-dialog"; -const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; +const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll"; +const RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\]\]|\[\s*来源\s*[::]\s*([A-Za-z0-9_-]{1,128})\s*\])/gi; +const TRAILING_RESEARCH_SOURCE_MARKER_RE = /(?:\[\[\s*source\s*[::][^\]\n]*(?:\]?)|\[\s*来源\s*[::][^\]\n]*)$/i; + +/** Keep durable source ids in Markdown while presenting plain numeric citations. */ +function formatResearchCitationsForPreview( + content: string, + sources: DeepResearchSource[], +): string { + const citationNumbers = new Map( + sources.map((source, index) => [source.id, index + 1]), + ); + let nextCitationNumber = citationNumbers.size + 1; + + return content + .replace(RESEARCH_SOURCE_MARKER_RE, (_marker, legacyId: string, displayId: string) => { + const sourceId = legacyId || displayId; + let citationNumber = citationNumbers.get(sourceId); + if (!citationNumber) { + citationNumber = nextCitationNumber; + nextCitationNumber += 1; + citationNumbers.set(sourceId, citationNumber); + } + return `[${citationNumber}]`; + }) + // Hide a citation token while it is still arriving over the live stream. + .replace(TRAILING_RESEARCH_SOURCE_MARKER_RE, ""); +} async function sha256Content(content: string): Promise { if (!globalThis.crypto?.subtle) return undefined; @@ -286,7 +324,13 @@ export function ArtifactFileDetail({ language === "mermaid" ); }, [language]); - const { content, isApplyingEdit } = useArtifactContent({ + const { + content, + isApplyingEdit, + isLoading: isArtifactContentLoading, + error: artifactContentError, + refetch: refetchArtifactContent, + } = useArtifactContent({ threadId, // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 @@ -326,10 +370,19 @@ export function ArtifactFileDetail({ 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 displayContent = documentRewriteActive + ? documentRewrite.run.draft + : (canEditArtifact ? draftContent : (content ?? "")); + const artifactContentPending = + isCodeFile && + !useWriteFileStreaming && + content === undefined && + isArtifactContentLoading; + const artifactContentUnavailable = + isCodeFile && + !useWriteFileStreaming && + content === undefined && + !isArtifactContentLoading; const isAutoPreviewLanguage = language === "html" || language === "markdown" || language === "mermaid"; const isPreviewStreaming = @@ -385,10 +438,12 @@ export function ArtifactFileDetail({ if ( !canEditArtifact || !threadId || - !filepath || - isMock || - useWriteFileStreaming || - documentRewriteActive + !filepath || + isMock || + useWriteFileStreaming || + documentRewriteActive || + content === undefined || + artifactContentError ) { return; } @@ -468,12 +523,13 @@ export function ArtifactFileDetail({ case "commit_started": rewritePresentation.update(presentationId, { stage: "committing" }); break; - case "committed": - rewritePresentation.update(presentationId, { - rewrittenContent: event.content, - versionId: event.versionId ?? null, - }); - break; + case "committed": + rewritePresentation.update(presentationId, { + rewrittenContent: event.content, + versionId: event.versionId ?? null, + committedHash: event.committedHash, + }); + break; case "done": rewritePresentation.update(presentationId, { stage: "completed", @@ -486,9 +542,9 @@ export function ArtifactFileDetail({ case "cancelled": rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message }); break; - case "error": - rewritePresentation.update(presentationId, { stage: "error", error: event.message }); - break; + case "error": + console.error("AI 全文改写历史恢复失败:", event.message); + break; default: break; } @@ -501,12 +557,15 @@ export function ArtifactFileDetail({ 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; + // still be active and needs its SSE stream resumed. + for (const historical of jobs.slice(1).reverse()) { + const historicalResult = historical.result; + const historicalCommittedHash = typeof historicalResult.committedHash === "string" + ? historicalResult.committedHash + : null; + 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 }, @@ -517,8 +576,9 @@ export function ArtifactFileDetail({ 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" + rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, + committedHash: historicalCommittedHash, + requirementsPlan: typeof historicalResult.requirementsPlan === "string" ? historicalResult.requirementsPlan : "", }); @@ -529,7 +589,7 @@ export function ArtifactFileDetail({ : historical.status === "cancelled" ? "cancelled" : "error", summary: historicalSummary, warnings: historicalSummary?.validationWarnings ?? [], - versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null, + versionId: typeof historicalResult.versionId === "string" ? historicalResult.versionId : null, error: historicalResult.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : historical.status === "cancelled" @@ -538,12 +598,28 @@ export function ArtifactFileDetail({ }); } 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({ + 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 versionSnapshotFailed = result.versionSnapshotFailed === true; + const committedHash = typeof result.committedHash === "string" ? result.committedHash : null; + if (!versionSnapshotFailed) { + clearArtifactRewriteFallback(threadId, filepath); + } + const localFallbackAvailable = + versionSnapshotFailed && + latest.status === "completed" && + result.status !== "conflict" && + committedHash !== null && + completeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent, + committedHash, + }); + const presentationId = rewritePresentation?.create({ threadId, source: { kind: "artifact", id: threadId, path: filepath }, fileName: getFileName(filepath), @@ -552,8 +628,10 @@ export function ArtifactFileDetail({ modelName: latest.request.modelName, generateImages: latest.request.generateImages, maxGeneratedImages: latest.request.maxGeneratedImages, - originalContent, - rewrittenContent, + originalContent, + rewrittenContent, + committedHash, + localFallbackAvailable, requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", }); if (!presentationId || !rewritePresentation) return; @@ -564,12 +642,19 @@ export function ArtifactFileDetail({ stage: result.status === "conflict" ? "conflict" : "completed", summary, warnings: summary?.validationWarnings ?? [], - versionId: typeof result.versionId === "string" ? result.versionId : null, + versionId: typeof result.versionId === "string" ? result.versionId : null, + committedHash, + localFallbackAvailable, error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null, }); - if (rewrittenContent && result.status !== "conflict") { - setDraftContent(rewrittenContent); - setHasLocalEdits(false); + if (rewrittenContent && result.status !== "conflict") { + setDraftContent(rewrittenContent); + setHasLocalEdits(versionSnapshotFailed); + if (versionSnapshotFailed) { + console.error( + "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", + ); + } queryClient.setQueriesData( { queryKey: ["artifact", filepath, threadId, isMock] }, { content: rewrittenContent, url: undefined }, @@ -603,10 +688,28 @@ export function ArtifactFileDetail({ signal, ), onEvent: (event) => updatePresentation(presentationId, event), - onCommitted: (rewritten) => { - setDraftContent(rewritten); - setHasLocalEdits(false); - rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); + onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { + setDraftContent(rewritten); + setHasLocalEdits(versionSnapshotFailed); + const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent, + committedHash, + }); + if (!versionSnapshotFailed) { + clearArtifactRewriteFallback(threadId, filepath); + } + if (versionSnapshotFailed) { + console.error( + "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", + ); + } + rewritePresentation.update(presentationId, { + rewrittenContent: rewritten, + committedHash, + localFallbackAvailable, + }); queryClient.setQueriesData( { queryKey: ["artifact", filepath, threadId, isMock] }, { content: rewritten, url: undefined }, @@ -621,9 +724,11 @@ export function ArtifactFileDetail({ return () => { disposed = true; }; - }, [ - canEditArtifact, - documentRewrite, + }, [ + artifactContentError, + canEditArtifact, + content, + documentRewrite, documentRewriteActive, draftContent, filepath, @@ -675,8 +780,29 @@ export function ArtifactFileDetail({ rewritePresentation.update(presentationId, { thinkingText: thinking }); }, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]); - useEffect(() => { - const handleRewriteUndo = (event: Event) => { + useEffect(() => { + const handleLocalRewriteUndoGuard = (event: Event) => { + const detail = (event as CustomEvent<{ + source?: { kind?: string; id?: string; path?: string }; + rewrittenContent?: string | null; + unchanged?: boolean; + }>).detail; + if ( + detail?.source?.kind !== "artifact" || + detail.source.id !== threadId || + detail.source.path !== filepath || + typeof detail.rewrittenContent !== "string" + ) { + return; + } + detail.unchanged = draftContent === detail.rewrittenContent; + }; + window.addEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard); + return () => window.removeEventListener(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, handleLocalRewriteUndoGuard); + }, [draftContent, filepath, threadId]); + + useEffect(() => { + const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ source?: { kind?: string; id?: string; path?: string }; content?: string; @@ -902,9 +1028,11 @@ export function ArtifactFileDetail({ }); setHasLocalEdits(false); toast.success("Markdown 已保存"); - } catch (error) { - console.error("Failed to save artifact:", error); - toast.error(error instanceof Error ? error.message : "Markdown 保存失败"); + } catch (error) { + console.error("Failed to save artifact:", error); + // Keep the draft and leave the button enabled. The caller can retry when + // the artifact endpoint recovers; this must never reset visible text. + setHasLocalEdits(true); } }, [ canEditArtifact, @@ -918,25 +1046,47 @@ export function ArtifactFileDetail({ const handleStartDocumentRewrite = useCallback( async (input: { - instruction: string; - style: "formal_analysis" | "professional" | "concise" | "readable"; - modelName: string | null; - generateImages: boolean; + instruction: string; + style: "formal_analysis" | "professional" | "concise" | "readable"; + modelName: string | null; + reportOutline: string; + generateImages: boolean; maxGeneratedImages: number; }) => { - if (!canUseFullDocumentRewrite) { - toast.error( - hasLocalEdits - ? "请先保存当前 Markdown 修改后再进行全文改写。" - : "当前文件暂不支持全文改写。", - ); - return; - } - setDocumentRewriteDialogOpen(false); - setAllowAutoViewSwitch(false); - setAutoScrollEnabled(true); - setViewMode("code"); - const expectedHash = await sha256Content(draftContent); + if (!canEditArtifact) { + toast.error("当前文件暂不支持全文改写。"); + return; + } + setDocumentRewriteDialogOpen(false); + setAllowAutoViewSwitch(false); + setAutoScrollEnabled(true); + setViewMode("code"); + + // Do not make users manually save before using AI rewrite. Persist their + // current draft first so the rewrite starts from exactly what they see, + // and the optimistic hash remains valid on the server. + if (hasLocalEdits) { + try { + await updateArtifact.mutateAsync({ + filepath, + threadId, + isMock, + content: draftContent, + }); + setHasLocalEdits(false); + } catch (error) { + console.error("Failed to save Markdown before AI rewrite:", error); + setHasLocalEdits(true); + return; + } + } + + primeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent: draftContent, + }); + const expectedHash = await sha256Content(draftContent); const presentationId = rewritePresentation?.create({ threadId, source: { kind: "artifact", id: threadId, path: filepath }, @@ -947,16 +1097,22 @@ export function ArtifactFileDetail({ generateImages: input.generateImages, maxGeneratedImages: input.maxGeneratedImages, originalContent: draftContent, - }); - setDocumentRewritePresentationId(presentationId ?? null); - void documentRewrite.start( + }); + setDocumentRewritePresentationId(presentationId ?? null); + window.dispatchEvent( + new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, { + detail: { threadId }, + }), + ); + void documentRewrite.start( { threadId, path: filepath, - instruction: input.instruction, - style: input.style, - modelName: input.modelName ?? undefined, - generateImages: input.generateImages, + instruction: input.instruction, + style: input.style, + modelName: input.modelName ?? undefined, + reportOutline: input.reportOutline || undefined, + generateImages: input.generateImages, maxGeneratedImages: input.maxGeneratedImages, expectedHash, }, @@ -1032,10 +1188,11 @@ export function ArtifactFileDetail({ case "commit_started": rewritePresentation.update(presentationId, { stage: "committing" }); break; - case "committed": - rewritePresentation.update(presentationId, { - versionId: event.versionId ?? null, - }); + case "committed": + rewritePresentation.update(presentationId, { + versionId: event.versionId ?? null, + committedHash: event.committedHash, + }); break; case "done": rewritePresentation.update(presentationId, { @@ -1049,21 +1206,33 @@ export function ArtifactFileDetail({ error: event.message, }); break; - case "error": - rewritePresentation.update(presentationId, { - stage: "error", - error: event.message, - }); - break; + case "error": + // The sandbox retains the visible draft and exposes manual + // save instead of showing an error state to the user. + console.error("AI 全文改写接口失败:", event.message); + break; default: break; } }, - onCommitted: (rewritten) => { - setDraftContent(rewritten); - setHasLocalEdits(false); - if (presentationId && rewritePresentation) { - rewritePresentation.update(presentationId, { rewrittenContent: rewritten }); + onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => { + setDraftContent(rewritten); + setHasLocalEdits(versionSnapshotFailed); + const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({ + threadId, + path: filepath, + originalContent: draftContent, + committedHash, + }); + if (!versionSnapshotFailed) { + clearArtifactRewriteFallback(threadId, filepath); + } + if (presentationId && rewritePresentation) { + rewritePresentation.update(presentationId, { + rewrittenContent: rewritten, + committedHash, + localFallbackAvailable, + }); } queryClient.setQueriesData( { @@ -1071,21 +1240,51 @@ export function ArtifactFileDetail({ }, { content: rewritten, url: undefined }, ); - toast.success("Markdown 全文改写完成,已安全替换原文件。"); - }, - }, - ); + if (versionSnapshotFailed) { + console.error( + "AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。", + ); + } else { + toast.success("Markdown 全文改写完成,已安全替换原文件。"); + } + }, + onFailure: ({ content, error }) => { + // The rewrite transport can fail after the server has already + // streamed text (or before the first token on a 404). Keep that + // visible content in the sandbox, make Save actionable, and leave + // diagnostics to the developer console only. + const retainedContent = content || draftContent; + setDraftContent(retainedContent); + setHasLocalEdits(true); + queryClient.setQueriesData( + { + queryKey: ["artifact", filepath, threadId, isMock], + }, + { content: retainedContent, url: undefined }, + ); + if (presentationId && rewritePresentation) { + rewritePresentation.update(presentationId, { + stage: "completed", + rewrittenContent: retainedContent, + error: null, + }); + } + console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error); + }, + }, + ); }, [ - canUseFullDocumentRewrite, - draftContent, - documentRewrite, - filepath, - hasLocalEdits, - isMock, - queryClient, - rewritePresentation, - threadId, + canEditArtifact, + draftContent, + documentRewrite, + filepath, + hasLocalEdits, + isMock, + queryClient, + rewritePresentation, + threadId, + updateArtifact, ], ); @@ -1331,13 +1530,15 @@ export function ArtifactFileDetail({ label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"} tooltip={ documentRewriteActive - ? "正在生成临时草稿,原文件尚未被覆盖" - : hasLocalEdits - ? "请先保存当前 Markdown 修改" - : "使用 AI 重写当前完整 Markdown" + ? "正在生成临时草稿,原文件尚未被覆盖" + : hasLocalEdits + ? "将先保存当前 Markdown 修改,再开始改写" + : "使用 AI 重写当前完整 Markdown" } - disabled={documentRewriteActive || !canUseFullDocumentRewrite} - onClick={() => setDocumentRewriteDialogOpen(true)} + // Keep the entry available after a completed rewrite and when + // the user has local edits. Starting with local edits saves + // them automatically in handleStartDocumentRewrite. + onClick={() => setDocumentRewriteDialogOpen(true)} /> )} {!useWriteFileStreaming && filepath.endsWith(".skill") && ( @@ -1509,7 +1710,28 @@ export function ArtifactFileDetail({ activeCursor={isApplyingEdit} /> )} - {isCodeFile && viewMode === "code" && ( + {artifactContentPending && viewMode === "code" && ( +
+
+ )} + {artifactContentUnavailable && viewMode === "code" && ( +
+
+ )} + {isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
(); - const [mermaidSvg, setMermaidSvg] = useState(null); - const [mermaidError, setMermaidError] = useState(null); - const markdownComponents = useMemo(() => { - const anchorResolver = createMarkdownAnchorResolver(content ?? ""); +}) { + const [htmlPreviewUrl, setHtmlPreviewUrl] = useState(); + const [mermaidSvg, setMermaidSvg] = useState(null); + const [mermaidError, setMermaidError] = useState(null); + const researchSourcesQuery = useResearchSources( + language === "markdown" ? (researchSessionId ?? null) : null, + ); + const previewContent = useMemo( + () => researchSessionId + ? formatResearchCitationsForPreview(content ?? "", researchSourcesQuery.data?.items ?? []) + : (content ?? ""), + [content, researchSessionId, researchSourcesQuery.data?.items], + ); + const markdownComponents = useMemo(() => { + const anchorResolver = createMarkdownAnchorResolver(previewContent); const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { const Tag = `h${level}` as const; return ({ children, id, ...props }: HTMLAttributes) => ( @@ -1785,7 +2016,7 @@ export function ArtifactFilePreview({ ); }, }; - }, [content, publicShareCode, researchSessionId, threadId]); + }, [previewContent, publicShareCode, researchSessionId, threadId]); useEffect(() => { if (language !== "html") { @@ -1874,8 +2105,8 @@ export function ArtifactFilePreview({ ), }} > - {content ?? ""} - + {previewContent} + {activeCursor && ( )} diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx index fe5116e4..03f3762a 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-list.tsx @@ -1,5 +1,5 @@ import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useState, type ReactNode } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -29,6 +29,7 @@ export function ArtifactFileList({ threadId, onOpenArtifact, onDownloadArtifact, + fileDescriptions, }: { className?: string; files: string[]; @@ -36,6 +37,8 @@ export function ArtifactFileList({ onOpenArtifact?: (filepath: string) => void; /** Optional host download action for virtual/session-owned artifacts. */ onDownloadArtifact?: (filepath: string) => void | Promise; + /** Optional per-file secondary text, such as the report structure used. */ + fileDescriptions?: Record; }) { const { t } = useI18n(); const { isMock, publicShareCode } = useThread(); @@ -102,10 +105,10 @@ export function ArtifactFileList({
{getFileIcon(file, "size-6")}
- - - {getFileExtensionDisplayName(file)} file - + + + {fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`} + {file.endsWith(".skill") && (