feat(沙箱): md 产物支持 AI 写作编辑与划词改写
- 新增 MarkdownAiEditor,复用 AI 写作的 TipTap 编辑器与 AI 改写流程 - 沙箱 md 增加 AI 写作视图切换,默认仍为预览;单文件隐藏文件名避免工具栏错位 - 单智能体聊天页开启 allowVirtualMarkdownEdit,生成完成后可编辑 - 会商总结页续看时自动收起研讨原料侧栏 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
44d7304f8e
commit
9268c73776
4 files changed
+230
-4
No files matched your search
@@ -11,6 +11,7 @@ import {
|
|||||||
LoaderIcon,
|
LoaderIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
|
SparklesIcon,
|
||||||
UploadCloudIcon,
|
UploadCloudIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||||
|
import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor";
|
||||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||||
import { useAgent } from "@/core/agents/hooks";
|
import { useAgent } from "@/core/agents/hooks";
|
||||||
import { apiFetch } from "@/core/api";
|
import { apiFetch } from "@/core/api";
|
||||||
@@ -213,6 +215,10 @@ export function ArtifactFileDetail({
|
|||||||
!useWriteFileStreaming &&
|
!useWriteFileStreaming &&
|
||||||
!isSkillFile &&
|
!isSkillFile &&
|
||||||
!isMock;
|
!isMock;
|
||||||
|
// 「AI 写作」md 展示开关:只要是可编辑的 markdown 就提供「AI 写作」视图(在 源码/预览 旁多一个
|
||||||
|
// 切换项),用户可随时切过去体验划词「AI 改写」。默认仍是「预览」,避免编辑器在内容加载期间
|
||||||
|
// 挂载导致白屏 + 避免复杂 md(表格/公式/HTML)误入有损编辑。
|
||||||
|
const canUseAiEditor = canEditArtifact;
|
||||||
const [draftContent, setDraftContent] = useState("");
|
const [draftContent, setDraftContent] = useState("");
|
||||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||||
const updateArtifact = useUpdateArtifactContent();
|
const updateArtifact = useUpdateArtifactContent();
|
||||||
@@ -230,7 +236,7 @@ export function ArtifactFileDetail({
|
|||||||
return "FILE";
|
return "FILE";
|
||||||
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
|
}, [filepath, isSkillFile, isSpreadsheetFile, language]);
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
const [viewMode, setViewMode] = useState<"code" | "preview" | "edit">("code");
|
||||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||||
@@ -267,6 +273,7 @@ export function ArtifactFileDetail({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// md/mermaid 默认预览(AI 写作视图作为可选切换项,由用户手动进入)。
|
||||||
if (isSupportPreview) {
|
if (isSupportPreview) {
|
||||||
setViewMode("preview");
|
setViewMode("preview");
|
||||||
return;
|
return;
|
||||||
@@ -598,7 +605,9 @@ export function ArtifactFileDetail({
|
|||||||
{fileTypeLabel}
|
{fileTypeLabel}
|
||||||
</span>
|
</span>
|
||||||
<ArtifactTitle className="min-w-0">
|
<ArtifactTitle className="min-w-0">
|
||||||
{useWriteFileStreaming ? (
|
{/* markdown:隐藏文件名(仅留 MD 徽标),把横向空间让给工具栏,避免窄面板下
|
||||||
|
工具栏换行错位;多文件仍保留切换下拉(靠它切文件)。文件名仍可在下载/另存为时取到。 */}
|
||||||
|
{language === "markdown" && !hasMultipleArtifacts ? null : useWriteFileStreaming ? (
|
||||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||||
) : hasMultipleArtifacts ? (
|
) : hasMultipleArtifacts ? (
|
||||||
<Select value={filepath} onValueChange={select}>
|
<Select value={filepath} onValueChange={select}>
|
||||||
@@ -638,10 +647,19 @@ export function ArtifactFileDetail({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setAllowAutoViewSwitch(false);
|
setAllowAutoViewSwitch(false);
|
||||||
setViewMode(value as "code" | "preview");
|
setViewMode(value as "code" | "preview" | "edit");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{canUseAiEditor && (
|
||||||
|
<ToggleGroupItem
|
||||||
|
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
||||||
|
value="edit"
|
||||||
|
aria-label="AI 写作编辑"
|
||||||
|
>
|
||||||
|
<SparklesIcon />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
)}
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
className="text-muted-foreground h-8 min-w-10 rounded-full border-0 bg-transparent px-3 shadow-none data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-[0_6px_14px_-10px_rgba(15,23,42,0.35)]"
|
||||||
value="code"
|
value="code"
|
||||||
@@ -803,6 +821,17 @@ export function ArtifactFileDetail({
|
|||||||
滑动到底部并继续自动下滑
|
滑动到底部并继续自动下滑
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{viewMode === "edit" && canUseAiEditor && (
|
||||||
|
<MarkdownAiEditor
|
||||||
|
// 编辑用原始内容(与源码模式一致,不脱敏);保存仍走头部「保存 Markdown」按钮。
|
||||||
|
markdown={displayContent ?? ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
setDraftContent(value);
|
||||||
|
setHasLocalEdits(true);
|
||||||
|
}}
|
||||||
|
threadId={threadId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isSupportPreview &&
|
{isSupportPreview &&
|
||||||
viewMode === "preview" &&
|
viewMode === "preview" &&
|
||||||
(language === "markdown" ||
|
(language === "markdown" ||
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ const ChatBox: React.FC<{
|
|||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||||
@@ -437,6 +438,7 @@ const ChatBox: React.FC<{
|
|||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
|
allowVirtualMarkdownEdit
|
||||||
/>
|
/>
|
||||||
) : hasCitationReferences ? (
|
) : hasCitationReferences ? (
|
||||||
<div className="relative size-full overflow-hidden">
|
<div className="relative size-full overflow-hidden">
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { CanvasThreadProvider } from "../../contexts/CanvasThreadContext";
|
||||||
|
import { CanvasUIProvider, useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||||
|
import { TiptapEditor } from "../../editor/TiptapEditor";
|
||||||
|
import { useWritingEditor } from "../../hooks/useWritingEditor";
|
||||||
|
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
|
||||||
|
import { applyInsert, applyReplace } from "../../utils/apply-rewrite";
|
||||||
|
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
||||||
|
|
||||||
|
import { WritingAiMenu } from "./WritingAiMenu";
|
||||||
|
import { WritingBubbleMenu } from "./WritingBubbleMenu";
|
||||||
|
import { WritingParagraphMenu } from "./WritingParagraphMenu";
|
||||||
|
import { WritingRewritePreview } from "./WritingRewritePreview";
|
||||||
|
|
||||||
|
interface MarkdownAiEditorProps {
|
||||||
|
/** 原始 markdown(编辑缓冲),由父级持有;编辑时通过 onChange 回写。 */
|
||||||
|
markdown: string;
|
||||||
|
/** 编辑回调:返回最新 markdown,父级据此 setDraftContent 并标记有改动。 */
|
||||||
|
onChange?: (markdown: string) => void;
|
||||||
|
/** 当前线程 id:透传给 AI 改写接口,便于后端结合上下文。 */
|
||||||
|
threadId?: string | null;
|
||||||
|
/** 只读:禁用编辑、AI 改写。 */
|
||||||
|
readOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelPos {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlowPhase = "idle" | "menu" | "preview";
|
||||||
|
|
||||||
|
interface LastAction {
|
||||||
|
action: RewriteAction;
|
||||||
|
tone?: RewriteTone;
|
||||||
|
customInstruction?: string;
|
||||||
|
referenceText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沙箱 md 的「AI 写作」编辑器:复用 AI 写作 那套 TipTap 编辑器 + 划词「AI 改写」气泡/段落
|
||||||
|
* 菜单 + 改写流式预览(润色/扩写/缩写/改语气/仿写/自定义指令)。自带 CanvasThread/CanvasUI
|
||||||
|
* Provider,故可直接嵌入任意页面,无需在外层包裹整页 Provider。
|
||||||
|
*
|
||||||
|
* 与 AI 写作 整页编辑器(TextArtifactRenderer)的差异:去掉对 CanvasGraphContext 的耦合(不
|
||||||
|
* 走全局 artifact 状态),去掉自带的工具栏/目录栏(保存/导出由宿主 ArtifactFileDetail 头部按钮
|
||||||
|
* 负责),更贴合侧边栏内的紧凑展示。
|
||||||
|
*/
|
||||||
|
export function MarkdownAiEditor(props: MarkdownAiEditorProps) {
|
||||||
|
return (
|
||||||
|
<CanvasThreadProvider initialThreadId={props.threadId ?? null}>
|
||||||
|
<CanvasUIProvider>
|
||||||
|
<MarkdownAiEditorInner {...props} />
|
||||||
|
</CanvasUIProvider>
|
||||||
|
</CanvasThreadProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownAiEditorInner({
|
||||||
|
markdown,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
className,
|
||||||
|
}: MarkdownAiEditorProps) {
|
||||||
|
const { writingSelection } = useCanvasUIContext();
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<FlowPhase>("idle");
|
||||||
|
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
|
||||||
|
const lastActionRef = useRef<LastAction | null>(null);
|
||||||
|
|
||||||
|
const editor = useWritingEditor({
|
||||||
|
markdown,
|
||||||
|
onChange,
|
||||||
|
editable: !readOnly,
|
||||||
|
placeholder: "空白文档…",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } =
|
||||||
|
useWritingRewrite(editor);
|
||||||
|
|
||||||
|
const computePos = useCallback((): PanelPos => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel && sel.rangeCount > 0) {
|
||||||
|
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||||
|
if (rect.width || rect.height) {
|
||||||
|
return { x: rect.left, y: rect.bottom + 6 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openAiMenu = useCallback(() => {
|
||||||
|
reset();
|
||||||
|
setPanelPos(computePos());
|
||||||
|
setPhase("menu");
|
||||||
|
}, [reset, computePos]);
|
||||||
|
|
||||||
|
const closeAll = useCallback(() => {
|
||||||
|
editor?.commands.clearAiHighlight();
|
||||||
|
setPhase("idle");
|
||||||
|
reset();
|
||||||
|
}, [reset, editor]);
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
(
|
||||||
|
action: RewriteAction,
|
||||||
|
tone?: RewriteTone,
|
||||||
|
customInstruction?: string,
|
||||||
|
referenceText?: string,
|
||||||
|
) => {
|
||||||
|
lastActionRef.current = { action, tone, customInstruction, referenceText };
|
||||||
|
rewrite(action, tone, customInstruction, referenceText);
|
||||||
|
setPhase("preview");
|
||||||
|
},
|
||||||
|
[rewrite],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRetry = useCallback(() => {
|
||||||
|
if (!lastActionRef.current) return;
|
||||||
|
const { action, tone, customInstruction, referenceText } = lastActionRef.current;
|
||||||
|
rewrite(action, tone, customInstruction, referenceText);
|
||||||
|
}, [rewrite]);
|
||||||
|
|
||||||
|
const handleFollowUp = useCallback(
|
||||||
|
(instruction: string) => {
|
||||||
|
if (!lastActionRef.current) return;
|
||||||
|
const { action, tone, referenceText } = lastActionRef.current;
|
||||||
|
rewriteFollowUp(instruction, action, tone, referenceText);
|
||||||
|
},
|
||||||
|
[rewriteFollowUp],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReplace = useCallback(() => {
|
||||||
|
if (!editor || !writingSelection || !result) return;
|
||||||
|
applyReplace(editor, writingSelection, result);
|
||||||
|
closeAll();
|
||||||
|
}, [editor, writingSelection, result, closeAll]);
|
||||||
|
|
||||||
|
const handleInsert = useCallback(() => {
|
||||||
|
if (!editor || !writingSelection || !result) return;
|
||||||
|
applyInsert(editor, writingSelection, result);
|
||||||
|
closeAll();
|
||||||
|
}, [editor, writingSelection, result, closeAll]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ?? "h-full flex flex-col overflow-hidden"}>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||||
|
{editor && !readOnly && (
|
||||||
|
<>
|
||||||
|
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||||
|
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && phase === "menu" && (
|
||||||
|
<WritingAiMenu
|
||||||
|
pos={panelPos}
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
onAction={handleAction}
|
||||||
|
onAbort={reset}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onClose={closeAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && phase === "preview" && (
|
||||||
|
<WritingRewritePreview
|
||||||
|
pos={panelPos}
|
||||||
|
status={status}
|
||||||
|
originalText={writingSelection?.text ?? ""}
|
||||||
|
result={result}
|
||||||
|
thinking={thinking}
|
||||||
|
error={error}
|
||||||
|
action={lastActionRef.current?.action ?? "polish"}
|
||||||
|
tone={lastActionRef.current?.tone}
|
||||||
|
onReplace={handleReplace}
|
||||||
|
onInsert={handleInsert}
|
||||||
|
onRetry={handleRetry}
|
||||||
|
onAbort={reset}
|
||||||
|
onClose={closeAll}
|
||||||
|
onFollowUp={handleFollowUp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -212,8 +212,10 @@ export function Step3SummaryMode({
|
|||||||
const displayDialogues = dialogues;
|
const displayDialogues = dialogues;
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||||||
|
// 初值固定为 false:这样即使「进入/续看时沙箱已是打开态」(rightPanelVisible 一上来就为 true),
|
||||||
|
// 首个 effect 也能识别 false→true 的边沿并收起左栏(修此前已打开时左栏不收起的问题)。
|
||||||
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
|
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
|
||||||
const prevRightPanelVisibleRef = useRef(rightPanelVisible);
|
const prevRightPanelVisibleRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
|
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
|
||||||
prevRightPanelVisibleRef.current = rightPanelVisible;
|
prevRightPanelVisibleRef.current = rightPanelVisible;
|
||||||
|
|||||||
Reference in new issue
Block a user