feat: 报告结构支持检索方向/来源与智能新增,并修复协助卡停轮与宿主跳转
为深度研究报告结构增加检索方向与技能来源、对话式智能新增页与详情排版;澄清中间件在 after_model 可靠结束本轮,避免弱模型出卡后继续生成;任务按钮补充 host 跳转与相关深链能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user