feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写
新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
11f928bc95
commit
a5a9c952e8
63 files changed
+4113
-929
No files matched your search
@@ -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<string | undefined> {
|
||||
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" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
|
||||
正在加载文档内容…
|
||||
</div>
|
||||
)}
|
||||
{artifactContentUnavailable && viewMode === "code" && (
|
||||
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 px-6 text-center text-sm">
|
||||
<FileType2Icon aria-hidden="true" className="size-6" />
|
||||
<p>{artifactContentError ? "暂时无法读取文档内容。" : "当前文档没有可显示的内容。"}</p>
|
||||
<button
|
||||
className="text-primary rounded-md px-2 py-1 font-medium underline-offset-4 hover:underline focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
void refetchArtifactContent();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
@@ -1724,12 +1946,21 @@ export function ArtifactFilePreview({
|
||||
researchSessionId?: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const markdownComponents = useMemo(() => {
|
||||
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(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<HTMLHeadingElement>) => (
|
||||
@@ -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 ?? ""}
|
||||
</Streamdown>
|
||||
{previewContent}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user