feat: 报告结构支持检索方向/来源与智能新增,并修复协助卡停轮与宿主跳转

为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-22 17:46:06 +08:00
1 parent a5a9c952e8
commit 51afebb394
67 files changed
+4511 -699

No files matched your search

@@ -43,11 +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,
useResearchSources,
type DeepResearchSource,
} from "@/strategy-components/api/deep-research";
import {
useCapabilities as useResearchCapabilities,
useResearchSources,
type DeepResearchSource,
} from "@/strategy-components/api/deep-research";
import {
listDurableDocumentRewrites,
streamDurableDocumentRewriteJob,
@@ -58,17 +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_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 {
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";
@@ -111,34 +111,55 @@ import { useArtifacts } from "./context";
import { SpreadsheetPreview } from "./spreadsheet-preview";
import { SkillInstallDialog } from "./skill-install-dialog";
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, "");
}
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 preserveMarkdownStandaloneLines(
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, ""),
);
}
/**
* Markdown treats a single newline as a space. Cover fields and chapter
* headings like 「日期:」「第一章」「1.1」must be their own blocks in preview.
*/
function preserveMarkdownStandaloneLines(markdown: string): string {
const standalone =
/^(?:《.+》|(?:日期|类型|密级)\s*[::].+|第[一二三四五六七八九十百千0-9]+章\b.*|\d+\.\d+(?:\.\d+)?\s+\S+)$/;
const lines = markdown.split("\n");
const output: string[] = [];
for (const line of lines) {
const stripped = line.trim();
if (stripped && standalone.test(stripped) && output.length > 0 && output[output.length - 1]!.trim()) {
output.push("");
}
output.push(line);
}
return output.join("\n");
}
async function sha256Content(content: string): Promise<string | undefined> {
if (!globalThis.crypto?.subtle) return undefined;
@@ -324,13 +345,13 @@ export function ArtifactFileDetail({
language === "mermaid"
);
}, [language]);
const {
content,
isApplyingEdit,
isLoading: isArtifactContentLoading,
error: artifactContentError,
refetch: refetchArtifactContent,
} = useArtifactContent({
const {
content,
isApplyingEdit,
isLoading: isArtifactContentLoading,
error: artifactContentError,
refetch: refetchArtifactContent,
} = useArtifactContent({
threadId,
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
@@ -370,19 +391,19 @@ export function ArtifactFileDetail({
const documentRewriteActive = documentRewrite.isActive;
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
const displayContent = documentRewriteActive
? documentRewrite.run.draft
: (canEditArtifact ? draftContent : (content ?? ""));
const artifactContentPending =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
isArtifactContentLoading;
const artifactContentUnavailable =
isCodeFile &&
!useWriteFileStreaming &&
content === undefined &&
!isArtifactContentLoading;
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 =
@@ -438,12 +459,12 @@ export function ArtifactFileDetail({
if (
!canEditArtifact ||
!threadId ||
!filepath ||
isMock ||
useWriteFileStreaming ||
documentRewriteActive ||
content === undefined ||
artifactContentError
!filepath ||
isMock ||
useWriteFileStreaming ||
documentRewriteActive ||
content === undefined ||
artifactContentError
) {
return;
}
@@ -523,13 +544,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,
committedHash: event.committedHash,
});
break;
case "committed":
rewritePresentation.update(presentationId, {
rewrittenContent: event.content,
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
case "done":
rewritePresentation.update(presentationId, {
stage: "completed",
@@ -542,9 +563,9 @@ export function ArtifactFileDetail({
case "cancelled":
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
case "error":
console.error("AI 全文改写历史恢复失败:", event.message);
break;
default:
break;
}
@@ -557,15 +578,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 historicalCommittedHash = typeof historicalResult.committedHash === "string"
? historicalResult.committedHash
: null;
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 },
@@ -576,9 +597,9 @@ export function ArtifactFileDetail({
generateImages: historical.request.generateImages,
maxGeneratedImages: historical.request.maxGeneratedImages,
originalContent: historical.request.originalContent,
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
committedHash: historicalCommittedHash,
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null,
committedHash: historicalCommittedHash,
requirementsPlan: typeof historicalResult.requirementsPlan === "string"
? historicalResult.requirementsPlan
: "",
});
@@ -589,7 +610,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"
@@ -598,28 +619,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 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({
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),
@@ -628,10 +649,10 @@ export function ArtifactFileDetail({
modelName: latest.request.modelName,
generateImages: latest.request.generateImages,
maxGeneratedImages: latest.request.maxGeneratedImages,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
originalContent,
rewrittenContent,
committedHash,
localFallbackAvailable,
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
});
if (!presentationId || !rewritePresentation) return;
@@ -642,19 +663,19 @@ export function ArtifactFileDetail({
stage: result.status === "conflict" ? "conflict" : "completed",
summary,
warnings: summary?.validationWarnings ?? [],
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
versionId: typeof result.versionId === "string" ? result.versionId : null,
committedHash,
localFallbackAvailable,
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
});
if (rewrittenContent && result.status !== "conflict") {
setDraftContent(rewrittenContent);
setHasLocalEdits(versionSnapshotFailed);
if (versionSnapshotFailed) {
console.error(
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
);
}
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 },
@@ -688,28 +709,28 @@ export function ArtifactFileDetail({
signal,
),
onEvent: (event) => updatePresentation(presentationId, event),
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,
});
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 },
@@ -724,11 +745,11 @@ export function ArtifactFileDetail({
return () => {
disposed = true;
};
}, [
artifactContentError,
canEditArtifact,
content,
documentRewrite,
}, [
artifactContentError,
canEditArtifact,
content,
documentRewrite,
documentRewriteActive,
draftContent,
filepath,
@@ -780,29 +801,29 @@ export function ArtifactFileDetail({
rewritePresentation.update(presentationId, { thinkingText: thinking });
}, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]);
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) => {
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;
@@ -1028,11 +1049,11 @@ export function ArtifactFileDetail({
});
setHasLocalEdits(false);
toast.success("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);
} 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,
@@ -1046,47 +1067,48 @@ export function ArtifactFileDetail({
const handleStartDocumentRewrite = useCallback(
async (input: {
instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
reportOutline: string;
generateImages: boolean;
instruction: string;
style: "formal_analysis" | "professional" | "concise" | "readable";
modelName: string | null;
reportOutline: string;
structureMode?: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}) => {
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);
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 },
@@ -1097,22 +1119,23 @@ export function ArtifactFileDetail({
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
originalContent: draftContent,
});
setDocumentRewritePresentationId(presentationId ?? null);
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_STARTED_EVENT, {
detail: { threadId },
}),
);
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,
reportOutline: input.reportOutline || undefined,
generateImages: input.generateImages,
instruction: input.instruction,
style: input.style,
modelName: input.modelName ?? undefined,
reportOutline: input.reportOutline || undefined,
structureMode: input.structureMode,
generateImages: input.generateImages,
maxGeneratedImages: input.maxGeneratedImages,
expectedHash,
},
@@ -1188,11 +1211,11 @@ export function ArtifactFileDetail({
case "commit_started":
rewritePresentation.update(presentationId, { stage: "committing" });
break;
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
case "committed":
rewritePresentation.update(presentationId, {
versionId: event.versionId ?? null,
committedHash: event.committedHash,
});
break;
case "done":
rewritePresentation.update(presentationId, {
@@ -1206,33 +1229,33 @@ export function ArtifactFileDetail({
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;
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, { 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,
});
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(
{
@@ -1240,51 +1263,51 @@ export function ArtifactFileDetail({
},
{ content: rewritten, url: undefined },
);
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);
},
},
);
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);
},
},
);
},
[
canEditArtifact,
draftContent,
documentRewrite,
filepath,
hasLocalEdits,
isMock,
queryClient,
rewritePresentation,
threadId,
updateArtifact,
canEditArtifact,
draftContent,
documentRewrite,
filepath,
hasLocalEdits,
isMock,
queryClient,
rewritePresentation,
threadId,
updateArtifact,
],
);
@@ -1530,15 +1553,15 @@ export function ArtifactFileDetail({
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
tooltip={
documentRewriteActive
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
? "正在生成临时草稿,原文件尚未被覆盖"
: hasLocalEdits
? "将先保存当前 Markdown 修改,再开始改写"
: "使用 AI 重写当前完整 Markdown"
}
// 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)}
// 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") && (
@@ -1710,28 +1733,28 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit}
/>
)}
{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 && (
{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"
@@ -1946,21 +1969,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 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 [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 ?? [])
: preserveMarkdownStandaloneLines(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>) => (
@@ -2016,7 +2039,7 @@ export function ArtifactFilePreview({
);
},
};
}, [previewContent, publicShareCode, researchSessionId, threadId]);
}, [previewContent, publicShareCode, researchSessionId, threadId]);
useEffect(() => {
if (language !== "html") {
@@ -2105,8 +2128,8 @@ export function ArtifactFilePreview({
),
}}
>
{previewContent}
</Streamdown>
{previewContent}
</Streamdown>
{activeCursor && (
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
)}
@@ -23,7 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ModelNameSelect } from "@/components/workspace/model-name-select";
import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite";
import { useReportStructures } from "@/strategy-components/api/report-structures";
import { useReportStructures, REPORT_STRUCTURE_MODE_OPTIONS, normalizeReportStructureMode, type ReportStructureMode } from "@/strategy-components/api/report-structures";
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
@@ -34,6 +34,7 @@ export interface DocumentRewriteDialogInput {
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
structureMode: "fixed" | "adaptive";
generateImages: boolean;
maxGeneratedImages: number;
}
@@ -99,6 +100,7 @@ export function DocumentRewriteDialog({
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
const [structureMode, setStructureMode] = useState<ReportStructureMode>("adaptive");
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
const reportStructuresQuery = useReportStructures({ status: "enabled" });
@@ -116,6 +118,7 @@ export function DocumentRewriteDialog({
setStyle("formal_analysis");
setModelName(null);
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
setStructureMode("adaptive");
setGenerateImages(false);
setImageCount("2");
}, [isNewReportGeneration, open]);
@@ -130,6 +133,11 @@ export function DocumentRewriteDialog({
));
}, [initialReportStructureId, open]);
useEffect(() => {
if (!selectedReportStructure) return;
setStructureMode(selectedReportStructure.structureMode);
}, [selectedReportStructure?.id, selectedReportStructure?.structureMode]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
@@ -139,6 +147,7 @@ export function DocumentRewriteDialog({
style,
modelName,
reportOutline: selectedReportStructure?.content ?? "",
structureMode,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
@@ -179,7 +188,7 @@ export function DocumentRewriteDialog({
/>
</label>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-medium">
{isNewReportGeneration ? "报告风格" : "改写风格"}
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
@@ -227,6 +236,28 @@ export function DocumentRewriteDialog({
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
结构约束
<Select
value={structureMode}
onValueChange={(value) => setStructureMode(normalizeReportStructureMode(value))}
>
<SelectTrigger aria-label="结构约束">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_STRUCTURE_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{REPORT_STRUCTURE_MODE_OPTIONS.find((option) => option.value === structureMode)?.description}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
@@ -131,6 +131,8 @@ export function MessageList({
onSetupWritingApprove,
onSetupWritingReject,
onSetupWritingStart,
onSetupReportStructureApprove,
onSetupReportStructureReject,
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
@@ -157,6 +159,8 @@ export function MessageList({
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
/** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */
onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupReportStructureApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onSetupReportStructureReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
/**
* 深度研究的交互卡插槽(报告配置、计划确认、失败重试):workbench 向
* displayThread 注入带 `additional_kwargs.setup_research_approval` 的虚拟 ai
@@ -621,6 +625,23 @@ export function MessageList({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-report-structure-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupReportStructureApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupReportStructureApprove}
onReject={onSetupReportStructureReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:research-approval") {
// 深度研究交互卡(配置/计划确认/失败重试):宿主不传渲染函数时该
// 分组不渲染。撰写步骤和完成摘要会经过普通的 processing/assistant 分组。
@@ -860,6 +881,15 @@ export type SetupWritingApprovalPayload = {
outline?: string;
};
export type SetupReportStructureApprovalPayload = {
title?: string;
name?: string;
content?: string;
structure_mode?: string;
retrieval_directions?: string[];
retrieval_skills?: string[];
};
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -1230,6 +1260,147 @@ function SetupWritingApprovalCard({
);
}
function getSetupReportStructureApprovalPayload(
message: unknown,
): SetupReportStructureApprovalPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const approval = Reflect.get(additionalKwargs, "setup_report_structure_approval");
if (typeof approval !== "object" || approval === null) {
return null;
}
return approval as SetupReportStructureApprovalPayload;
}
const STRUCTURE_MODE_LABELS: Record<string, string> = {
fixed: "固定大纲",
adaptive: "弹性大纲",
};
function SetupReportStructureApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupReportStructureApprovalPayload) => void | Promise<void>;
}) {
const payload = getSetupReportStructureApprovalPayload(message);
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(null);
if (!payload) {
return null;
}
const approvalPayload = payload;
const busy = disabled || submitting !== null;
const directions = approvalPayload.retrieval_directions ?? [];
const skills = approvalPayload.retrieval_skills ?? [];
async function run(action: "approve" | "reject") {
if (busy) {
return;
}
setSubmitting(action);
try {
if (action === "approve") {
await onApprove?.(approvalPayload);
} else {
await onReject?.(approvalPayload);
}
} finally {
setSubmitting(null);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
</div>
{approvalPayload.title ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">标题</div>
<p className="text-sm font-medium">{approvalPayload.title}</p>
</div>
) : null}
{approvalPayload.name ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">描述</div>
<p className="text-sm leading-6">{approvalPayload.name}</p>
</div>
) : null}
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{approvalPayload.structure_mode ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">结构约束</div>
<p className="text-sm font-medium">
{STRUCTURE_MODE_LABELS[approvalPayload.structure_mode] ?? approvalPayload.structure_mode}
</p>
</div>
) : null}
{directions.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索方向</div>
<p className="text-sm font-medium">{directions.join("、")}</p>
</div>
) : null}
{skills.length > 0 ? (
<div className="space-y-0.5">
<div className="text-muted-foreground text-xs">检索来源</div>
<p className="text-sm font-medium">{skills.join("、")}</p>
</div>
) : null}
</div>
{approvalPayload.content ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">报告大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.content}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;