feat: 深度研究支持独立新报告与原文改写,并完善沙箱、模型管理与全文改写

新报告不再覆盖当前文件卡片,写入时打开沙箱;步骤条仅在改写原文时位于卡片上方。同时补齐改写本地回退、模型管理与会商第三步相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-21 21:46:04 +08:00
1 parent 11f928bc95
commit a5a9c952e8
63 files changed
+4113 -929

No files matched your search

@@ -346,15 +346,22 @@ export function AgentGallery() {
请输入访问密码,验证通过后才能查看其中的智能体。
</p>
</div>
<div className="flex flex-col gap-2">
<form
className="flex flex-col gap-2"
onSubmit={(event) => {
event.preventDefault();
handleSubmitPassword();
}}
>
<label className="sr-only" htmlFor="private-square-access-code">
访问密码
</label>
<Input
id="private-square-access-code"
type="password"
placeholder="访问密码"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmitPassword();
}}
// This is a one-off access code, not a login credential. Tell
// browsers / password managers not to save or autofill it (which
// would also stop them from spilling a saved account into the
@@ -365,8 +372,8 @@ export function AgentGallery() {
data-lpignore="true"
data-form-type="other"
/>
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
</div>
<Button type="submit">进入私密广场</Button>
</form>
</div>
);
}
@@ -14,11 +14,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
import type { CompactShortcutRouteParam } from "@/core/system-settings/api";
import { useAppearanceSettings, useLayoutMode } from "@/core/system-settings/hooks";
import { isHideChromeActive } from "@/core/embed/embed-session";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
import { buildLightAppUrl, resolveRouteParamValue } from "@/strategy-components/lib/light-app";
/**
* 换肤 / 界面模式 顶栏控件。深色顶栏(Header)与简洁模式页面级顶栏(ChatPage 等)共用,
@@ -47,6 +48,34 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground";
function appendShortcutPathParams(url: string, params: CompactShortcutRouteParam[]) {
const pathSegments = params
.filter((param) => param.paramType === "path")
.map((param) =>
resolveRouteParamValue({ ...param, paramType: "login" }).trim(),
)
.filter(Boolean)
.map((value) => encodeURIComponent(value));
if (pathSegments.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex < 0) {
const [path, query = ""] = url.split("?", 2);
return `${path.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
const beforeHash = url.slice(0, hashIndex);
const fragment = url.slice(hashIndex + 1);
const [routePath, query = ""] = fragment.split("?", 2);
return `${beforeHash}#${routePath.replace(/\/+$/, "")}/${pathSegments.join("/")}${query ? `?${query}` : ""}`;
}
function isQueryShortcutParam(
param: CompactShortcutRouteParam,
): param is CompactShortcutRouteParam & { paramType: "login" | "theme" } {
return param.paramType === "login" || param.paramType === "theme";
}
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
const { data: appearance } = useAppearanceSettings();
const buttons = (appearance?.compact_shortcut_buttons ?? [])
@@ -69,9 +98,10 @@ export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }
)}
title={button.label}
onClick={() => {
const routeParams = (button.route_params ?? []).map((param) => ({ ...param }));
const url = buildLightAppUrl({
url: button.url,
routeParams: button.route_params.map((param) => ({ ...param })),
url: appendShortcutPathParams(button.url, routeParams),
routeParams: routeParams.filter(isQueryShortcutParam),
});
window.open(url, "_blank", "noopener,noreferrer");
}}
@@ -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]" />
)}
@@ -1,5 +1,5 @@
import { DownloadIcon, LoaderIcon, PackageIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -29,6 +29,7 @@ export function ArtifactFileList({
threadId,
onOpenArtifact,
onDownloadArtifact,
fileDescriptions,
}: {
className?: string;
files: string[];
@@ -36,6 +37,8 @@ export function ArtifactFileList({
onOpenArtifact?: (filepath: string) => void;
/** Optional host download action for virtual/session-owned artifacts. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional per-file secondary text, such as the report structure used. */
fileDescriptions?: Record<string, ReactNode>;
}) {
const { t } = useI18n();
const { isMock, publicShareCode } = useThread();
@@ -102,10 +105,10 @@ export function ArtifactFileList({
<div className="absolute top-2 -left-0.5">
{getFileIcon(file, "size-6")}
</div>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{getFileExtensionDisplayName(file)} file
</CardDescription>
</CardTitle>
<CardDescription className="min-w-0 pl-8 text-xs">
{fileDescriptions?.[file] ?? `${getFileExtensionDisplayName(file)} file`}
</CardDescription>
<CardAction className="row-span-1 self-center">
{file.endsWith(".skill") && (
<Button
@@ -1,7 +1,7 @@
"use client";
import { ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { FilePenLineIcon, ImagePlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -23,6 +23,20 @@ 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";
const KEEP_CURRENT_REPORT_STRUCTURE = "__keep_current_report_structure__";
const DEFAULT_REWRITE_INSTRUCTION = "统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。";
const DEFAULT_GENERATE_INSTRUCTION = "基于已选资料按所选报告结构生成一篇新的完整报告;保持事实可核验,不补充没有依据的信息。";
export interface DocumentRewriteDialogInput {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
reportOutline: string;
generateImages: boolean;
maxGeneratedImages: number;
}
const STYLE_OPTIONS: { value: DocumentRewriteStyle; label: string; description: string }[] = [
{
@@ -54,7 +68,13 @@ export function DocumentRewriteDialog({
charCount,
imageGenerationAvailable = false,
maxGeneratedImages = 4,
initialReportOutline,
operationLabel,
operationDescription,
submitLabel,
operation,
onStart,
onRewriteOriginal,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -62,64 +82,106 @@ export function DocumentRewriteDialog({
charCount: number;
imageGenerationAvailable?: boolean;
maxGeneratedImages?: number;
onStart: (input: {
instruction: string;
style: DocumentRewriteStyle;
modelName: string | null;
generateImages: boolean;
maxGeneratedImages: number;
}) => void;
/** A saved report structure selected before the dialog was opened. */
initialReportOutline?: string;
/** Optional copy for flows that create a new document instead of replacing one. */
operationLabel?: string;
operationDescription?: string;
submitLabel?: string;
/** ``generate`` creates an independent report; ``rewrite`` edits this report in place. */
operation?: "rewrite" | "generate";
onStart: (input: DocumentRewriteDialogInput) => void;
/** Optional secondary action for generation flows that rewrites the source report in place. */
onRewriteOriginal?: (input: DocumentRewriteDialogInput) => void;
}) {
const [instruction, setInstruction] = useState(
"统一表达风格,减少重复内容,改善段落衔接;不新增原文没有依据的事实。",
);
const isNewReportGeneration = operation === "generate";
const [instruction, setInstruction] = useState(DEFAULT_REWRITE_INSTRUCTION);
const [style, setStyle] = useState<DocumentRewriteStyle>("formal_analysis");
const [modelName, setModelName] = useState<string | null>(null);
const [reportStructureId, setReportStructureId] = useState(KEEP_CURRENT_REPORT_STRUCTURE);
const [generateImages, setGenerateImages] = useState(false);
const [imageCount, setImageCount] = useState("2");
const reportStructuresQuery = useReportStructures({ status: "enabled" });
const reportStructures = reportStructuresQuery.data ?? [];
const selectedReportStructure = reportStructures.find((item) => item.id === reportStructureId);
const initialReportStructureId = useMemo(() => {
const outline = initialReportOutline?.trim();
if (!outline) return null;
return reportStructures.find((item) => item.content.trim() === outline)?.id ?? null;
}, [initialReportOutline, reportStructures]);
useEffect(() => {
if (!open) return;
setInstruction(isNewReportGeneration ? DEFAULT_GENERATE_INSTRUCTION : DEFAULT_REWRITE_INSTRUCTION);
setStyle("formal_analysis");
setModelName(null);
setReportStructureId(KEEP_CURRENT_REPORT_STRUCTURE);
setGenerateImages(false);
setImageCount("2");
}, [open]);
}, [isNewReportGeneration, open]);
// The structures query may resolve after the dialog opens. Only seed the
// value while the user still has the default selection, so a manual choice
// is never overwritten by a query refresh.
useEffect(() => {
if (!open || !initialReportStructureId) return;
setReportStructureId((current) => (
current === KEEP_CURRENT_REPORT_STRUCTURE ? initialReportStructureId : current
));
}, [initialReportStructureId, open]);
const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style);
const canStart = instruction.trim().length > 0;
const startWith = (handler: (input: DocumentRewriteDialogInput) => void) => {
if (!canStart) return;
handler({
instruction: instruction.trim(),
style,
modelName,
reportOutline: selectedReportStructure?.content ?? "",
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-primary" />
AI 全文改写
{operationLabel ?? "AI 全文改写"}
</DialogTitle>
<DialogDescription>
将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。
{operationDescription ?? "将以当前文件快照生成临时草稿。校验通过前不会覆盖原 Markdown。"}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border bg-muted/35 px-3 py-2.5 text-sm">
<div className="font-medium">{fileName}</div>
<div className="text-muted-foreground mt-1">原文约 {charCount.toLocaleString()} 字</div>
<div className="text-muted-foreground mt-1">
{isNewReportGeneration ? "来源报告" : "原文"}约 {charCount.toLocaleString()} 字
</div>
</div>
<label className="grid gap-2 text-sm font-medium">
改写要求
{isNewReportGeneration ? "生成要求" : "改写要求"}
<Textarea
value={instruction}
onChange={(event) => setInstruction(event.target.value)}
placeholder="例如:统一为正式行业分析语气,减少重复,不新增外部事实。"
placeholder={isNewReportGeneration
? "例如:突出政策影响,并按所选结构组织章节;仅使用已选资料。"
: "例如:统一为正式行业分析语气,减少重复,不新增外部事实。"}
rows={4}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-3">
<label className="grid gap-2 text-sm font-medium">
改写风格
{isNewReportGeneration ? "报告风格" : "改写风格"}
<Select value={style} onValueChange={(value) => setStyle(value as DocumentRewriteStyle)}>
<SelectTrigger>
<SelectValue />
@@ -135,6 +197,36 @@ export function DocumentRewriteDialog({
<span className="text-muted-foreground text-xs">{selectedStyle?.description}</span>
</label>
<label className="grid gap-2 text-sm font-medium">
报告结构
<Select value={reportStructureId} onValueChange={setReportStructureId}>
<SelectTrigger aria-label="报告结构">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={KEEP_CURRENT_REPORT_STRUCTURE}>沿用当前结构</SelectItem>
{reportStructuresQuery.isLoading && (
<SelectItem value="__report_structures_loading__" disabled>
正在加载结构…
</SelectItem>
)}
{reportStructuresQuery.isError && (
<SelectItem value="__report_structures_error__" disabled>
报告结构库加载失败
</SelectItem>
)}
{reportStructures.map((structure) => (
<SelectItem key={structure.id} value={structure.id}>
{structure.title}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">
{selectedReportStructure?.name || "默认保留原文的章节安排。"}
</span>
</label>
<label className="grid gap-2 text-sm font-medium">
模型
<ModelNameSelect
@@ -143,7 +235,9 @@ export function DocumentRewriteDialog({
triggerClassName="h-10 text-sm"
defaultLabel="系统默认模型"
/>
<span className="text-muted-foreground text-xs">仅影响本次全文改写。</span>
<span className="text-muted-foreground text-xs">
仅影响本次{isNewReportGeneration ? "新报告生成" : "全文改写"}。
</span>
</label>
</div>
@@ -155,14 +249,16 @@ export function DocumentRewriteDialog({
<ImagePlusIcon className="size-4" />
</span>
<span className="grid gap-0.5">
<span className="text-sm font-medium">改写后补充配图</span>
<span className="text-sm font-medium">
{isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
</span>
<span className="text-muted-foreground text-xs leading-5">
根据章节生成并保存为当前沙箱的本地图片,不使用外部图片链接。
</span>
</span>
</label>
<Switch
aria-label="改写后补充配图"
aria-label={isNewReportGeneration ? "为新报告补充配图" : "改写后补充配图"}
checked={generateImages}
id="document-rewrite-images"
onCheckedChange={setGenerateImages}
@@ -193,24 +289,22 @@ export function DocumentRewriteDialog({
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
{isNewReportGeneration && onRewriteOriginal && (
<Button
variant="outline"
disabled={!canStart}
onClick={() => startWith(onRewriteOriginal)}
>
<FilePenLineIcon />
改写原文
</Button>
)}
<Button
disabled={!canStart}
onClick={() => {
if (!canStart) return;
onStart({
instruction: instruction.trim(),
style,
modelName,
generateImages,
maxGeneratedImages: Math.min(
Math.max(Number(imageCount) || 1, 1),
Math.max(Math.min(maxGeneratedImages, 4), 1),
),
});
}}
onClick={() => startWith(onStart)}
>
<SparklesIcon />
开始改写
{submitLabel ?? "开始改写"}
</Button>
</DialogFooter>
</DialogContent>
@@ -3,13 +3,14 @@
import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
FilePenLineIcon,
LightbulbIcon,
Loader2Icon,
Undo2Icon,
XCircleIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -19,17 +20,26 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { apiFetch } from "@/core/api/fetch-client";
import { updateArtifactContent } from "@/core/artifacts/api";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
DOCUMENT_REWRITE_UNDONE_EVENT,
type DocumentRewritePresentation,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import {
clearArtifactRewriteFallback,
loadArtifactRewriteFallback,
sha256DocumentContent,
} from "@/core/document-rewrite/local-fallback";
import { getBackendBaseURL } from "@/core/config";
import { cn } from "@/lib/utils";
import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
const STEPS = [
const REWRITE_STEPS = [
{ key: "snapshot", label: "固定原文版本" },
{ key: "requirements", label: "理解改写要求" },
{ key: "writing", label: "流式重写 Markdown" },
@@ -38,6 +48,15 @@ const STEPS = [
{ key: "comparison", label: "对比原文并生成说明" },
] as const;
const GENERATE_STEPS = [
{ key: "snapshot", label: "固定来源报告快照" },
{ key: "requirements", label: "确认用户生成需求" },
{ key: "writing", label: "流式生成 Markdown" },
{ key: "illustrations", label: "生成文档配图" },
{ key: "validation", label: "校验新报告" },
{ key: "comparison", label: "生成报告说明" },
] as const;
function stepPosition(stage: DocumentRewritePresentation["stage"]): number {
switch (stage) {
case "snapshot":
@@ -68,11 +87,75 @@ function isTerminal(stage: DocumentRewritePresentation["stage"]) {
function RewriteThinkingTrace({ text, active }: { text: string; active: boolean }) {
const [open, setOpen] = useState(active);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const contentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const previousActiveRef = useRef(active);
const lastAutoScrollTopRef = useRef<number | null>(null);
useEffect(() => {
if (active && text) setOpen(true);
}, [active, text]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
lastAutoScrollTopRef.current = null;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = contentRef.current;
if (!content) return;
content.scrollTop = content.scrollHeight;
lastAutoScrollTopRef.current = content.scrollTop;
}, []);
useLayoutEffect(() => {
if (!active || !open || !text) return;
scrollToLatestThinking();
}, [active, open, scrollToLatestThinking, text]);
useEffect(() => {
if (!active || !open || !text) return;
const content = contentRef.current;
if (!content) return;
let frame: number | null = null;
const observer = new MutationObserver(() => {
if (frame !== null) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
frame = null;
scrollToLatestThinking();
});
});
observer.observe(content, { childList: true, characterData: true, subtree: true });
return () => {
observer.disconnect();
if (frame !== null) cancelAnimationFrame(frame);
};
}, [active, open, scrollToLatestThinking, text]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current) return;
const lastAutoScrollTop = lastAutoScrollTopRef.current;
if (
lastAutoScrollTop === null ||
Math.abs(event.currentTarget.scrollTop - lastAutoScrollTop) > 1
) {
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
}
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
if (!text.trim()) return null;
return (
<Collapsible className="mt-2 max-w-3xl" onOpenChange={setOpen} open={open}>
@@ -94,9 +177,29 @@ function RewriteThinkingTrace({ text, active }: { text: string; active: boolean
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{text}
</pre>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={contentRef}
>
{text}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
@@ -107,6 +210,8 @@ export function DocumentRewriteTimeline({
}: {
item: DocumentRewritePresentation;
}) {
const isNewReportGeneration = item.operation === "generate";
const steps = isNewReportGeneration ? GENERATE_STEPS : REWRITE_STEPS;
const current = stepPosition(item.stage);
const failed = ["conflict", "error", "cancelled"].includes(item.stage);
const completed = item.stage === "completed";
@@ -119,9 +224,79 @@ export function DocumentRewriteTimeline({
const reapplying = Boolean(item.revertedAt);
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
const canUseLocalFallback =
!reapplying &&
!activeVersionId &&
item.source.kind === "artifact" &&
typeof item.source.path === "string" &&
item.localFallbackAvailable &&
typeof item.committedHash === "string";
const handleUndo = async () => {
if (!activeVersionId || undoing) return;
if ((!activeVersionId && !canUseLocalFallback) || undoing) return;
if (canUseLocalFallback && item.source.kind === "artifact" && item.source.path && item.committedHash) {
setUndoing(true);
try {
const fallback = loadArtifactRewriteFallback({
threadId: item.source.id,
path: item.source.path,
committedHash: item.committedHash,
});
if (!fallback) {
console.error("本地 AI 全文改写撤销备份不可用,未修改当前文件。");
return;
}
const currentResponse = await apiFetch(
urlOfArtifact({ filepath: item.source.path, threadId: item.source.id }),
);
if (!currentResponse.ok) {
throw new Error(`读取当前文件失败(${currentResponse.status})。`);
}
const currentHash = await sha256DocumentContent(await currentResponse.text());
if (!currentHash || currentHash !== fallback.committedHash) {
console.error("当前文件已在改写后发生变化,已跳过本地 AI 全文改写撤销。");
return;
}
const guard = new CustomEvent<{
source: DocumentRewritePresentation["source"];
rewrittenContent: string | null;
unchanged: boolean;
}>(DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT, {
detail: {
source: item.source,
rewrittenContent: item.rewrittenContent,
unchanged: false,
},
});
window.dispatchEvent(guard);
if (!guard.detail.unchanged) {
console.error("沙箱中存在改写后的未保存编辑,已跳过本地 AI 全文改写撤销。");
return;
}
await updateArtifactContent({
filepath: item.source.path,
threadId: item.source.id,
content: fallback.originalContent,
});
clearArtifactRewriteFallback(item.source.id, item.source.path);
presentation?.update(item.id, {
localFallbackAvailable: false,
revertedAt: new Date().toISOString(),
});
window.dispatchEvent(
new CustomEvent(DOCUMENT_REWRITE_UNDONE_EVENT, {
detail: { source: item.source, content: fallback.originalContent },
}),
);
} catch (error) {
console.error("本地 AI 全文改写撤销失败:", error);
} finally {
setUndoing(false);
}
return;
}
if (!activeVersionId) return;
const baseUrl = getBackendBaseURL();
const endpoint =
item.source.kind === "artifact"
@@ -158,7 +333,15 @@ export function DocumentRewriteTimeline({
detail: { source: item.source, content },
}),
);
toast.success(reapplying ? "已重新应用本次 AI 全文改写版本。" : "已恢复到本次 AI 全文改写前的版本。");
toast.success(
isNewReportGeneration
? reapplying
? "已重新应用本次生成的新报告版本。"
: "已恢复本次生成前的新报告快照。"
: reapplying
? "已重新应用本次 AI 全文改写版本。"
: "已恢复到本次 AI 全文改写前的版本。",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "撤销改写失败,请稍后重试。");
} finally {
@@ -174,7 +357,9 @@ export function DocumentRewriteTimeline({
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">AI 全文改写 · {item.fileName}</h3>
<h3 className="font-semibold">
{isNewReportGeneration ? "生成新报告" : "AI 全文改写"} · {item.fileName}
</h3>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
@@ -185,7 +370,7 @@ export function DocumentRewriteTimeline({
: "bg-primary/10 text-primary",
)}
>
{completed ? "已完成" : failed ? "未覆盖原文件" : "进行中"}
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
</span>
</div>
<p className="text-muted-foreground mt-1 text-sm">
@@ -195,7 +380,7 @@ export function DocumentRewriteTimeline({
</header>
<div className="space-y-3 px-5 py-4">
{STEPS.map((step, index) => {
{steps.map((step, index) => {
const active = !isTerminal(item.stage) && index === current;
const illustrationSkipped = step.key === "illustrations" && !item.generateImages;
const illustrationFailed =
@@ -228,7 +413,7 @@ export function DocumentRewriteTimeline({
<div className="text-sm font-medium">{step.label}</div>
{step.key === "snapshot" && item.originalCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
已冻结 {item.originalCharCount.toLocaleString()} 字原文快照。
已冻结 {item.originalCharCount.toLocaleString()} 字{isNewReportGeneration ? "来源报告" : "原文"}快照。
</p>
)}
{step.key === "requirements" && (
@@ -242,18 +427,28 @@ export function DocumentRewriteTimeline({
</div>
) : active ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在识别改写目标、保留约束与执行方式…
{isNewReportGeneration
? "正在确认新报告的结构、资料范围与生成要求…"
: "正在识别改写目标、保留约束与执行方式…"}
</p>
) : (
<p className="text-muted-foreground mt-0.5 text-xs">
已根据改写要求确定执行计划。
{isNewReportGeneration ? "已根据所选结构确定生成计划。" : "已根据改写要求确定执行计划。"}
</p>
)
)}
{step.key === "writing" && item.writtenCharCount > 0 && (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已生成 {item.writtenCharCount.toLocaleString()} 字。
</p>
{step.key === "writing" && (
item.writtenCharCount > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
</p>
) : active ? (
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
{isNewReportGeneration
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
</p>
) : null
)}
{step.key === "illustrations" && (
illustrationSkipped ? (
@@ -292,9 +487,10 @@ export function DocumentRewriteTimeline({
{item.summary && (
<div className="border-t px-5 py-4 text-sm leading-6">
<div className="mb-2 font-semibold">改写说明</div>
<div className="mb-2 font-semibold">{isNewReportGeneration ? "生成说明" : "改写说明"}</div>
<p>
原文 {item.summary.originalCharCount.toLocaleString()} 字,改写后{" "}
{isNewReportGeneration ? "来源报告" : "原文"} {item.summary.originalCharCount.toLocaleString()} 字,
{isNewReportGeneration ? "新报告" : "改写后"}{" "}
{item.summary.rewrittenCharCount.toLocaleString()} 字;调整了约{" "}
{item.summary.changedBlockCount} 个内容块。
</p>
@@ -340,7 +536,25 @@ export function DocumentRewriteTimeline({
查看前后对比
</Button>
)}
{completed && activeVersionId && (
{isNewReportGeneration && completed && item.source.kind === "deep_research_report" && (
<Button
className="mt-3 mr-2"
onClick={() => {
window.dispatchEvent(
new CustomEvent(DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT, {
detail: { sessionId: item.source.id },
}),
);
}}
size="sm"
type="button"
variant="outline"
>
<FilePenLineIcon className="mr-1.5 size-3.5" />
改写新报告
</Button>
)}
{completed && (activeVersionId || canUseLocalFallback) && (
<Button
className="mt-3"
disabled={undoing}
@@ -350,12 +564,19 @@ export function DocumentRewriteTimeline({
variant="outline"
>
{undoing ? <Loader2Icon className="mr-1.5 size-3.5 animate-spin" /> : <Undo2Icon className="mr-1.5 size-3.5" />}
{reapplying ? "重新应用此次改写" : "撤销此次改写"}
{isNewReportGeneration
? reapplying
? "重新应用本次生成"
: "撤销本次生成"
: reapplying
? "重新应用此次改写"
: "撤销此次改写"}
</Button>
)}
{item.revertedAt && (
<p className="mt-2 text-xs text-emerald-700 dark:text-emerald-300">
已恢复为改写前版本。{item.redoVersionId ? "可重新应用本次改写。" : ""}
已恢复为{isNewReportGeneration ? "生成前" : "改写前"}版本。
{item.redoVersionId ? `可重新应用本次${isNewReportGeneration ? "生成" : "改写"}。` : ""}
</p>
)}
</div>
@@ -1,8 +1,9 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
CheckCircle2Icon,
ChevronDownIcon,
CheckCircle2Icon,
ChevronDownIcon,
ChevronsDownIcon,
ChevronUp,
FolderOpenIcon,
GlobeIcon,
@@ -15,7 +16,7 @@ import {
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type AnchorHTMLAttributes } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type AnchorHTMLAttributes } from "react";
import {
ChainOfThought,
@@ -603,13 +604,64 @@ function DeepResearchProgressStep({
? args.label
: "正在撰写研究报告";
const detail = typeof args.detail === "string" ? args.detail.trim() : "";
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
const thinking = typeof args.thinking === "string" ? args.thinking.trim() : "";
const active = args.status !== "complete";
const [thinkingOpen, setThinkingOpen] = useState(false);
const [autoScrollPaused, setAutoScrollPaused] = useState(false);
const thinkingContentRef = useRef<HTMLPreElement>(null);
const autoScrollEnabledRef = useRef(true);
const programmaticScrollRef = useRef(false);
const previousActiveRef = useRef(active);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (active && thinking) setThinkingOpen(true);
}, [active, thinking]);
useEffect(() => {
if (active && !previousActiveRef.current) {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
}
previousActiveRef.current = active;
}, [active]);
const scrollToLatestThinking = useCallback(() => {
if (!autoScrollEnabledRef.current) return;
const content = thinkingContentRef.current;
if (!content) return;
// Scroll events do not identify their initiator reliably across browsers.
// Mark this assignment so it never disables following as if it were a
// user drag; a user-originated scroll away from the bottom still pauses it.
programmaticScrollRef.current = true;
content.scrollTop = content.scrollHeight;
requestAnimationFrame(() => {
programmaticScrollRef.current = false;
});
}, []);
useLayoutEffect(() => {
if (!active || !thinkingOpen || !thinking) return;
scrollToLatestThinking();
// A second frame covers the browser's delayed reflow of a growing <pre>.
const frame = requestAnimationFrame(scrollToLatestThinking);
return () => cancelAnimationFrame(frame);
}, [active, thinking, thinkingOpen, scrollToLatestThinking]);
const pauseAutoScrollOnManualScroll = (event: React.UIEvent<HTMLPreElement>) => {
if (!active || !autoScrollEnabledRef.current || programmaticScrollRef.current) return;
const { clientHeight, scrollHeight, scrollTop } = event.currentTarget;
// Being at the bottom is still the follow state. Only an actual move away
// from it (wheel, touch, keyboard or scrollbar drag) pauses auto-follow.
if (scrollTop + clientHeight >= scrollHeight - 4) return;
autoScrollEnabledRef.current = false;
setAutoScrollPaused(true);
};
const resumeAutoScroll = () => {
autoScrollEnabledRef.current = true;
setAutoScrollPaused(false);
requestAnimationFrame(scrollToLatestThinking);
};
return (
<ChainOfThoughtStep
@@ -652,9 +704,29 @@ function DeepResearchProgressStep({
<p aria-live={active ? "polite" : undefined} className="sr-only" role={active ? "status" : undefined}>
{active ? "模型思考内容正在更新" : "模型思考内容已完成"}
</p>
<pre className="bg-muted/45 text-muted-foreground mt-1 max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words">
{thinking}
</pre>
<div className="relative mt-1">
<pre
className="bg-muted/45 text-muted-foreground max-h-56 overflow-auto rounded-lg border px-3 py-2 text-xs leading-5 whitespace-pre-wrap break-words"
onScroll={pauseAutoScrollOnManualScroll}
ref={thinkingContentRef}
>
{thinking}
</pre>
{active && autoScrollPaused && (
<Button
aria-label="恢复思考内容自动下滑"
className="absolute top-1/2 left-1/2 h-9 -translate-x-1/2 -translate-y-1/2 gap-1.5 bg-background/95 px-3 text-xs shadow-md backdrop-blur-sm hover:bg-background"
onClick={resumeAutoScroll}
size="sm"
title="恢复自动下滑"
type="button"
variant="outline"
>
<ChevronsDownIcon className="size-3.5" />
自动下滑
</Button>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
@@ -12,7 +12,10 @@ import {
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps";
import { useDocumentRewritePresentation } from "@/core/document-rewrite/context";
import {
DOCUMENT_REWRITE_STARTED_EVENT,
useDocumentRewritePresentation,
} from "@/core/document-rewrite/context";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -89,6 +92,22 @@ function ScrollToBottomOnUserMessage({
}
}, [messages, scrollToBottom]);
useEffect(() => {
const handleDocumentRewriteStarted = (event: Event) => {
const detail = (event as CustomEvent<{ threadId?: string }>).detail;
if (detail?.threadId !== threadId) return;
// The presentation card is inserted by a context update in the same
// interaction. Two frames let React commit it before the smooth scroll.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void scrollToBottom("smooth");
});
});
};
window.addEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
return () => window.removeEventListener(DOCUMENT_REWRITE_STARTED_EVENT, handleDocumentRewriteStarted);
}, [scrollToBottom, threadId]);
return null;
}
@@ -115,6 +134,7 @@ export function MessageList({
researchApprovalSlot,
onOpenArtifact,
onDownloadArtifact,
afterDocumentRewriteItems,
}: {
className?: string;
contentClassName?: string;
@@ -147,6 +167,8 @@ export function MessageList({
onOpenArtifact?: (filepath: string) => void;
/** Host override for downloading virtual/session-owned files in a present-files card. */
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
/** Optional host content placed immediately after document-rewrite timelines. */
afterDocumentRewriteItems?: React.ReactNode;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
@@ -470,7 +492,15 @@ export function MessageList({
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
{(() => {
const rewriteItemsAboveFile = documentRewriteItems.filter(
(item) => item.operation !== "generate",
);
const rewriteItemsBelowFile = documentRewriteItems.filter(
(item) => item.operation === "generate",
);
let didRenderRewriteAboveFile = false;
const grouped = groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
// 深度研究的报告摘要由独立 SSE 作业驱动;把它接入既有 assistant 流式状态,
@@ -608,8 +638,15 @@ export function MessageList({
files.push(...presentFiles);
}
}
const rewriteAbove = !didRenderRewriteAboveFile
? rewriteItemsAboveFile
: [];
didRenderRewriteAboveFile = true;
return (
<div className="w-full" key={group.id}>
{rewriteAbove.map((item) => (
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
))}
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
@@ -752,10 +789,20 @@ export function MessageList({
/>
</div>
);
})}
{documentRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
});
const leftoverRewriteItems = didRenderRewriteAboveFile
? rewriteItemsBelowFile
: documentRewriteItems;
return (
<>
{grouped}
{leftoverRewriteItems.map((item) => (
<DocumentRewriteTimeline key={item.id} item={item} />
))}
{afterDocumentRewriteItems}
</>
);
})()}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
@@ -117,7 +117,7 @@ function normalizeShortcutDraft(
? button.route_params
: [newShortcutParam()]
).map((param) => ({
key: param.key || "",
key: param.paramType === "path" ? "" : param.key || "",
paramType: param.paramType || "login",
source: param.source || "access_token",
customValue: param.customValue ?? "",
@@ -132,8 +132,18 @@ function cleanShortcutParams(
params: CompactShortcutRouteParam[],
): CompactShortcutRouteParam[] {
return params
.filter((param) => param.key.trim())
.filter((param) => param.paramType === "path" || param.key.trim())
.map((param) => {
if (param.paramType === "path") {
const source = param.source || "access_token";
return {
key: "",
paramType: "path" as const,
source,
customValue:
source === "other" ? (param.customValue ?? "").trim() : undefined,
};
}
const base: CompactShortcutRouteParam = {
key: param.key.trim(),
paramType: param.paramType,
@@ -354,6 +364,13 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
return;
}
for (const param of item.route_params) {
if (param.paramType === "path") {
if (param.source === "other" && !param.customValue?.trim()) {
toast.error("路径参数自定义值不可为空");
return;
}
continue;
}
if (!param.key.trim()) {
toast.error("路由参数键名不可为空");
return;
@@ -548,7 +565,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
<Separator />
<SettingsSection
title="顶部快捷按钮(管理员)"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停和与应用管理一致的登录/主题参数。"
description="配置全量/简洁模式右上角、换肤按钮左侧显示的跳转按钮。支持多个按钮、启停,以及登录、主题和路径参数。"
>
<CompactShortcutSettings
buttons={shortcutDraft}
@@ -915,7 +932,7 @@ function CompactShortcutSettings({
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground text-xs">
已配置 {buttons.length} 个按钮;只有启用且填写了跳转链接的按钮会显示。
已配置 {buttons.length} 个按钮;支持追加登录、主题及路径参数。
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={onAdd}>
@@ -1012,7 +1029,7 @@ function CompactShortcutSettings({
</div>
<div className="rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm font-medium">
<ExternalLink className="size-4" />
路由参数
@@ -1028,13 +1045,13 @@ function CompactShortcutSettings({
</Button>
</div>
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
{button.route_params.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed bg-background/70 p-4 text-center text-xs">
不追加额外参数。
</div>
) : (
<div className="space-y-2">
{button.route_params.map((param, paramIndex) => {
const themeMap =
param.paramType === "theme"
? parseThemeMap(param.themeValue)
@@ -1044,26 +1061,36 @@ function CompactShortcutSettings({
key={`${button.id}-${paramIndex}`}
className="space-y-2 rounded-md border bg-background p-2"
>
<div className="grid gap-2 md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]">
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
<div
className={cn(
"grid gap-2",
param.paramType === "path"
? "md:grid-cols-[130px_minmax(180px,1fr)_32px]"
: "md:grid-cols-[minmax(120px,1fr)_130px_minmax(180px,1.4fr)_32px]",
)}
>
{param.paramType !== "path" && (
<Input
value={param.key}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
key: event.target.value,
})
}
placeholder="参数名 key"
className="font-mono text-xs"
/>
)}
<Select
value={param.paramType}
onValueChange={(value) =>
onPatchParam(button.id, paramIndex, {
paramType: value as "login" | "theme",
paramType: value as "login" | "theme" | "path",
key: value === "path" ? "" : param.key,
source:
value === "login"
? param.source || "access_token"
: undefined,
value === "theme"
? undefined
: param.source || "access_token",
})
}
>
@@ -1073,9 +1100,10 @@ function CompactShortcutSettings({
<SelectContent>
<SelectItem value="login">登录参数</SelectItem>
<SelectItem value="theme">主题参数</SelectItem>
<SelectItem value="path">路径参数</SelectItem>
</SelectContent>
</Select>
{param.paramType === "login" ? (
{(param.paramType === "login" || param.paramType === "path") ? (
<div className="flex min-w-0 gap-2">
<Select
value={param.source || "access_token"}
@@ -1101,6 +1129,11 @@ function CompactShortcutSettings({
</Select>
{param.source === "other" && (
<Input
// This config value becomes a query parameter;
// it is not a login form field. A password input
// makes Chrome invoke password-manager heuristics
// whenever the settings dialog opens.
type="text"
value={param.customValue ?? ""}
onChange={(event) =>
onPatchParam(button.id, paramIndex, {
@@ -1109,6 +1142,8 @@ function CompactShortcutSettings({
}
placeholder="自定义值"
className="w-32 text-xs"
autoComplete="off"
aria-label="自定义参数值"
/>
)}
</div>
@@ -1131,6 +1166,12 @@ function CompactShortcutSettings({
</Button>
</div>
{param.paramType === "path" && (
<p className="text-muted-foreground text-xs">
不填写参数名,按添加顺序拼到链接或 Hash 路由的路径末尾。
</p>
)}
{param.paramType === "theme" && (
<div className="grid gap-2 rounded-md bg-muted/40 p-2 md:grid-cols-3">
{APP_THEME_COLORS.map((color) => (
@@ -1164,9 +1205,9 @@ function CompactShortcutSettings({
)}
</div>
);
})}
</div>
)}
})}
</div>
)}
</div>
</div>
))}
@@ -100,13 +100,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
path: "/page/workspace/deep-research",
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
},
{
id: "workspace-enterprise-research",
label: "企业深度研究",
icon: FileSearch,
path: "/page/workspace/enterprise-research",
match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
},
// {
// id: "workspace-enterprise-research",
// label: "企业深度研究",
// icon: FileSearch,
// path: "/page/workspace/enterprise-research",
// match: (pathname) => pathname.startsWith("/page/workspace/enterprise-research"),
// },
{
id: "workspace-roundtable-planning",
label: "多智能体规划",
@@ -7,6 +7,7 @@ import {
BriefcaseIcon,
CalendarClockIcon,
ChevronsUpDown,
CpuIcon,
DatabaseZapIcon,
GaugeIcon,
HistoryIcon,
@@ -227,6 +228,14 @@ export function WorkspaceNavMenu() {
技能管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<CpuIcon />
模型配置
</DropdownMenuItem>
)}
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
{isAdmin && (