handleTextPointerUp(content.fullMarkdown)}
- onScroll={handleScroll}
- >
-
- {/* Selection badge */}
- {hasSelection && selectionPreview && (
-
-
+
+
+ {/* Selection badge */}
+ {hasSelection && selectionPreview && (
+
+
+
+ {selection.textSelection ? "选中文本:" : "选中代码:"}
+
+ {selectionPreview}
+
+
+
+ )}
+
+ {/* Textarea */}
diff --git a/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx b/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
index 78f8213a..3241dd0a 100644
--- a/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
+++ b/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
@@ -8,6 +8,7 @@ import {
} from "@/components/ui/resizable";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
+import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
@@ -25,6 +26,7 @@ export function CanvasResizableLayout() {
}, [isChatCollapsed]);
return (
+
+
);
}
diff --git a/frontend-web/src/open-canvas/components/layout/CanvasShell.tsx b/frontend-web/src/open-canvas/components/layout/CanvasShell.tsx
index 93a42020..5eeb92e4 100644
--- a/frontend-web/src/open-canvas/components/layout/CanvasShell.tsx
+++ b/frontend-web/src/open-canvas/components/layout/CanvasShell.tsx
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { getCanvasThread } from "../../api/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
+import { TooltipProvider } from "@/components/ui/tooltip";
import { CanvasHeader } from "./CanvasHeader";
import { CanvasResizableLayout } from "./CanvasResizableLayout";
@@ -42,9 +43,11 @@ export function CanvasShell() {
}, [threadId, setMessages, setArtifact, setThreadId]);
return (
-
-
-
-
+
+
+
+
+
+
);
}
diff --git a/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx b/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx
new file mode 100644
index 00000000..8353c6e5
--- /dev/null
+++ b/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx
@@ -0,0 +1,24 @@
+import { createContext, useContext, type ReactNode } from "react";
+import { useCanvasThreadStream } from "../hooks/useCanvasThreadStream";
+
+type CanvasStreamContextValue = ReturnType
;
+
+const CanvasStreamContext = createContext(null);
+
+export function CanvasStreamProvider({ children }: { children: ReactNode }) {
+ const value = useCanvasThreadStream();
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCanvasStreamContext() {
+ const ctx = useContext(CanvasStreamContext);
+ if (!ctx)
+ throw new Error(
+ "useCanvasStreamContext must be used within CanvasStreamProvider",
+ );
+ return ctx;
+}
diff --git a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx
index d66f35b6..6f08bcdc 100644
--- a/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx
+++ b/frontend-web/src/open-canvas/contexts/CanvasUIContext.tsx
@@ -10,6 +10,7 @@ import type {
CanvasCodeSelection,
CanvasSelectionState,
CanvasTextSelection,
+ WritingSelection,
} from "../types/selection";
export interface CanvasUIContextValue {
@@ -17,6 +18,8 @@ export interface CanvasUIContextValue {
isEditing: boolean;
isWebSearchOpen: boolean;
selection: CanvasSelectionState;
+ /** Tiptap editor selection for AI rewrite flow */
+ writingSelection: WritingSelection | null;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
@@ -25,6 +28,7 @@ export interface CanvasUIContextValue {
/** Set code selection; clears any existing text selection. */
setCodeSelection: (s: CanvasCodeSelection | null) => void;
clearSelection: () => void;
+ setWritingSelection: (s: WritingSelection | null) => void;
}
const EMPTY_SELECTION: CanvasSelectionState = {
@@ -40,6 +44,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [selection, setSelection] =
useState(EMPTY_SELECTION);
+ const [writingSelection, setWritingSelection] =
+ useState(null);
const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
setSelection({ textSelection: s, codeSelection: null });
@@ -56,12 +62,14 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isEditing,
isWebSearchOpen,
selection,
+ writingSelection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
+ setWritingSelection,
}}
>
{children}
diff --git a/frontend-web/src/open-canvas/editor/AiHighlightExtension.ts b/frontend-web/src/open-canvas/editor/AiHighlightExtension.ts
new file mode 100644
index 00000000..3f79f340
--- /dev/null
+++ b/frontend-web/src/open-canvas/editor/AiHighlightExtension.ts
@@ -0,0 +1,79 @@
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+
+interface HighlightRange {
+ from: number;
+ to: number;
+}
+
+const aiHighlightKey = new PluginKey("aiHighlight");
+
+// Augment Tiptap's Commands interface so TS knows about our custom commands
+declare module "@tiptap/core" {
+ interface Commands {
+ aiHighlight: {
+ setAiHighlight: (from: number, to: number) => ReturnType;
+ clearAiHighlight: () => ReturnType;
+ };
+ }
+}
+
+export const AiHighlightExtension = Extension.create({
+ name: "aiHighlight",
+
+ addCommands() {
+ return {
+ setAiHighlight:
+ (from: number, to: number) =>
+ ({ dispatch, tr }) => {
+ if (dispatch) dispatch(tr.setMeta(aiHighlightKey, { from, to }));
+ return true;
+ },
+ clearAiHighlight:
+ () =>
+ ({ dispatch, tr }) => {
+ if (dispatch) dispatch(tr.setMeta(aiHighlightKey, null));
+ return true;
+ },
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: aiHighlightKey,
+
+ state: {
+ init: () => null,
+ apply(tr, prev) {
+ const meta = tr.getMeta(aiHighlightKey);
+ // explicit set / clear via command
+ if (meta !== undefined) return meta as HighlightRange | null;
+ // map positions through doc changes so highlight survives edits
+ if (tr.docChanged && prev) {
+ return {
+ from: tr.mapping.map(prev.from),
+ to: tr.mapping.map(prev.to),
+ };
+ }
+ return prev;
+ },
+ },
+
+ props: {
+ decorations(state) {
+ const range = aiHighlightKey.getState(state);
+ if (!range) return DecorationSet.empty;
+ const { from, to } = range;
+ const docSize = state.doc.content.size;
+ if (from >= to || from < 0 || to > docSize) return DecorationSet.empty;
+ return DecorationSet.create(state.doc, [
+ Decoration.inline(from, to, { class: "ai-rewrite-highlight" }),
+ ]);
+ },
+ },
+ }),
+ ];
+ },
+});
diff --git a/frontend-web/src/open-canvas/editor/TiptapEditor.tsx b/frontend-web/src/open-canvas/editor/TiptapEditor.tsx
new file mode 100644
index 00000000..de7b53d7
--- /dev/null
+++ b/frontend-web/src/open-canvas/editor/TiptapEditor.tsx
@@ -0,0 +1,11 @@
+import type { Editor } from "@tiptap/react";
+import { EditorContent } from "@tiptap/react";
+
+interface TiptapEditorProps {
+ editor: Editor | null;
+ className?: string;
+}
+
+export function TiptapEditor({ editor, className }: TiptapEditorProps) {
+ return ;
+}
diff --git a/frontend-web/src/open-canvas/editor/TiptapExtensions.ts b/frontend-web/src/open-canvas/editor/TiptapExtensions.ts
new file mode 100644
index 00000000..69e56948
--- /dev/null
+++ b/frontend-web/src/open-canvas/editor/TiptapExtensions.ts
@@ -0,0 +1,23 @@
+import StarterKit from "@tiptap/starter-kit";
+import Underline from "@tiptap/extension-underline";
+import Placeholder from "@tiptap/extension-placeholder";
+import { Markdown } from "tiptap-markdown";
+import { AiHighlightExtension } from "./AiHighlightExtension";
+
+export const buildExtensions = (placeholder = "开始写作…") => [
+ StarterKit.configure({
+ // Disable hard break so Enter creates new paragraph
+ hardBreak: false,
+ }),
+ Underline,
+ Placeholder.configure({
+ placeholder,
+ emptyEditorClass: "is-editor-empty",
+ }),
+ Markdown.configure({
+ html: false,
+ transformCopiedText: true,
+ transformPastedText: true,
+ }),
+ AiHighlightExtension,
+];
diff --git a/frontend-web/src/open-canvas/hooks/useCanvasQuickActions.ts b/frontend-web/src/open-canvas/hooks/useCanvasQuickActions.ts
index 3457f016..11d3beef 100644
--- a/frontend-web/src/open-canvas/hooks/useCanvasQuickActions.ts
+++ b/frontend-web/src/open-canvas/hooks/useCanvasQuickActions.ts
@@ -1,13 +1,13 @@
import { useCallback } from "react";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
+import { useCanvasStreamContext } from "../contexts/CanvasStreamContext";
import type { QuickActionId } from "../types/quick-action";
import { getCurrentArtifactContent } from "../utils/artifact";
import { buildQuickActionMessage } from "../utils/quick-actions";
-import { useCanvasStream } from "./useCanvasStream";
export function useCanvasQuickActions() {
- const { sendMessage } = useCanvasStream();
+ const { sendMessage } = useCanvasStreamContext();
const { artifact } = useCanvasGraphContext();
const triggerQuickAction = useCallback(
@@ -15,7 +15,7 @@ export function useCanvasQuickActions() {
const content = getCurrentArtifactContent(artifact);
if (!content) return;
- const message = buildQuickActionMessage(actionId);
+ const message = buildQuickActionMessage(actionId, content);
await sendMessage(message, { quick_action_id: actionId });
},
[sendMessage, artifact],
diff --git a/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts b/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts
index 3b61c1c5..52576708 100644
--- a/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts
+++ b/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
-import { getAPIClient } from "@/core/api";
+import { getCanvasAPIClient } from "@/core/api/canvas-client";
import { useUpdateSubtask } from "@/core/tasks/context";
import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
@@ -61,7 +61,7 @@ export function useCanvasThreadStream() {
}, [threadId]);
const thread = useStream({
- client: getAPIClient(),
+ client: getCanvasAPIClient(),
assistantId: CANVAS_AGENT_ID,
threadId: onStreamThreadId,
fetchStateHistory: { limit: 1 },
@@ -70,7 +70,7 @@ export function useCanvasThreadStream() {
threadIdRef.current = meta.thread_id;
setThreadId(meta.thread_id);
navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
- void getAPIClient()
+ void getCanvasAPIClient()
.threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
.catch(() => {});
},
@@ -183,8 +183,10 @@ export function useCanvasThreadStream() {
},
]);
- // Pass current artifact info so the backend can instruct the LLM to
- // continue editing the same artifact instead of creating a new one.
+ // Inject current artifact content into the human message so the LLM can see
+ // what to edit. create_agent only passes state.messages to the LLM — state.artifact
+ // is NOT automatically visible. Skip injection when the caller already embedded
+ // the artifact (e.g. quick actions build the full message themselves).
const currentContent = artifact ? getCurrentArtifactContent(artifact) : null;
const artifactContext = currentContent
? {
@@ -193,11 +195,26 @@ export function useCanvasThreadStream() {
}
: {};
+ const messageText =
+ currentContent && !trimmed.includes(" {
+ const body =
+ currentContent.type === "code"
+ ? currentContent.code
+ : currentContent.fullMarkdown;
+ const tag =
+ currentContent.type === "code"
+ ? `language="${currentContent.language}"`
+ : "";
+ return `${trimmed}\n\n\n${body}\n`;
+ })()
+ : trimmed;
+
try {
await thread.submit(
{
messages: [
- { type: "human", content: [{ type: "text", text: trimmed }] },
+ { type: "human", content: [{ type: "text", text: messageText }] },
],
},
{
diff --git a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts
new file mode 100644
index 00000000..f15f666c
--- /dev/null
+++ b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts
@@ -0,0 +1,53 @@
+import { useEffect, useRef } from "react";
+import { useEditor } from "@tiptap/react";
+import { buildExtensions } from "../editor/TiptapExtensions";
+
+interface UseWritingEditorOptions {
+ markdown: string;
+ onChange?: (markdown: string) => void;
+ editable?: boolean;
+ placeholder?: string;
+}
+
+export function useWritingEditor({
+ markdown,
+ onChange,
+ editable = true,
+ placeholder,
+}: UseWritingEditorOptions) {
+ const prevMarkdownRef = useRef(markdown);
+
+ const editor = useEditor({
+ extensions: buildExtensions(placeholder),
+ editable,
+ content: markdown,
+ onUpdate({ editor }) {
+ if (!onChange) return;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
+ onChange(md);
+ },
+ });
+
+ // Sync external markdown changes (e.g. after streaming completes)
+ useEffect(() => {
+ if (!editor) return;
+ if (markdown === prevMarkdownRef.current) return;
+ prevMarkdownRef.current = markdown;
+
+ const { from, to } = editor.state.selection;
+ editor.commands.setContent(markdown, { emitUpdate: false });
+ const docSize = editor.state.doc.content.size;
+ if (from <= docSize && to <= docSize) {
+ editor.commands.setTextSelection({ from, to });
+ }
+ }, [editor, markdown]);
+
+ // Sync editable flag
+ useEffect(() => {
+ if (!editor) return;
+ editor.setEditable(editable);
+ }, [editor, editable]);
+
+ return editor;
+}
diff --git a/frontend-web/src/open-canvas/hooks/useWritingRewrite.ts b/frontend-web/src/open-canvas/hooks/useWritingRewrite.ts
new file mode 100644
index 00000000..ab70fcaf
--- /dev/null
+++ b/frontend-web/src/open-canvas/hooks/useWritingRewrite.ts
@@ -0,0 +1,206 @@
+import { useCallback, useRef, useState } from "react";
+import type { Editor } from "@tiptap/react";
+import {
+ streamRewrite,
+ type RewriteAction,
+ type RewriteRequest,
+ type RewriteTone,
+} from "../api/rewrite";
+import { useCanvasUIContext } from "../contexts/CanvasUIContext";
+import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
+
+export type RewriteStatus = "idle" | "loading" | "success" | "error";
+
+// ── … tag stripper ────────────────────────────────────────────
+// Handles tags split across multiple streaming chunks.
+class ThinkTagParser {
+ private buf = "";
+ private inside = false;
+
+ push(chunk: string): { thinking: string; text: string } {
+ this.buf += chunk;
+ let thinking = "";
+ let text = "";
+
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ if (!this.inside) {
+ const idx = this.buf.indexOf("");
+ if (idx !== -1) {
+ text += this.buf.slice(0, idx);
+ this.buf = this.buf.slice(idx + 7);
+ this.inside = true;
+ } else {
+ // hold last 6 chars to guard against a partial opening tag
+ const safe = Math.max(0, this.buf.length - 6);
+ text += this.buf.slice(0, safe);
+ this.buf = this.buf.slice(safe);
+ break;
+ }
+ } else {
+ const idx = this.buf.indexOf("");
+ if (idx !== -1) {
+ thinking += this.buf.slice(0, idx);
+ this.buf = this.buf.slice(idx + 8);
+ this.inside = false;
+ } else {
+ // hold last 7 chars to guard against a partial closing tag
+ const safe = Math.max(0, this.buf.length - 7);
+ thinking += this.buf.slice(0, safe);
+ this.buf = this.buf.slice(safe);
+ break;
+ }
+ }
+ }
+
+ return { thinking, text };
+ }
+
+ flush(): { thinking: string; text: string } {
+ const out = {
+ thinking: this.inside ? this.buf : "",
+ text: this.inside ? "" : this.buf,
+ };
+ this.buf = "";
+ return out;
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function useWritingRewrite(editor: Editor | null) {
+ const { writingSelection } = useCanvasUIContext();
+ const { threadId, modelName } = useCanvasThreadContext();
+
+ const [status, setStatus] = useState("idle");
+ const [result, setResult] = useState(null);
+ const [thinking, setThinking] = useState(null);
+ const [error, setError] = useState(null);
+
+ const abortRef = useRef(null);
+
+ // Shared streaming runner — state setters from useState are stable references
+ const runStream = useCallback(
+ async (req: RewriteRequest, abort: AbortController) => {
+ const parser = new ThinkTagParser();
+ const applyParsed = (parsed: { thinking: string; text: string }) => {
+ if (parsed.thinking) setThinking((p) => (p ?? "") + parsed.thinking);
+ if (parsed.text) setResult((p) => (p ?? "") + parsed.text);
+ };
+ try {
+ let terminated = false;
+ for await (const event of streamRewrite(req, abort.signal)) {
+ if (abort.signal.aborted) break;
+ if (event.type === "thinking") {
+ setThinking((p) => (p ?? "") + event.chunk);
+ } else if (event.type === "text") {
+ applyParsed(parser.push(event.chunk));
+ } else if (event.type === "done") {
+ applyParsed(parser.flush());
+ setStatus("success");
+ terminated = true;
+ break;
+ } else if (event.type === "error") {
+ setError(event.message ?? "改写失败,请重试");
+ setStatus("error");
+ terminated = true;
+ break;
+ }
+ }
+ if (!terminated && !abort.signal.aborted) {
+ applyParsed(parser.flush());
+ setStatus("success");
+ }
+ } catch (e) {
+ if (abort.signal.aborted) return;
+ setError(e instanceof Error ? e.message : "改写失败,请重试");
+ setStatus("error");
+ }
+ },
+ [], // eslint-disable-line react-hooks/exhaustive-deps
+ );
+
+ const rewrite = useCallback(
+ async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
+ if (!writingSelection || !threadId || !editor) return;
+
+ abortRef.current?.abort();
+ const abort = new AbortController();
+ abortRef.current = abort;
+
+ const { from, to, text } = writingSelection;
+ const docText = editor.state.doc.textContent;
+ const docSize = editor.state.doc.content.size;
+
+ const req: RewriteRequest = {
+ threadId,
+ selection: { from, to, text },
+ action,
+ tone,
+ modelName: modelName ?? undefined,
+ surroundingContext: {
+ before: docText.substring(Math.max(0, from - 400), from),
+ after: docText.substring(to, Math.min(docSize, to + 400)),
+ },
+ customInstruction: customInstruction || undefined,
+ };
+
+ setStatus("loading");
+ setResult(null);
+ setThinking(null);
+ setError(null);
+ await runStream(req, abort);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [writingSelection, threadId, modelName, editor, runStream],
+ );
+
+ const rewriteFollowUp = useCallback(
+ async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
+ if (!writingSelection || !threadId || !editor) return;
+ const prevResult = result;
+ if (!prevResult) return;
+
+ abortRef.current?.abort();
+ const abort = new AbortController();
+ abortRef.current = abort;
+
+ const { from, to, text } = writingSelection;
+ const docText = editor.state.doc.textContent;
+ const docSize = editor.state.doc.content.size;
+
+ const req: RewriteRequest = {
+ threadId,
+ selection: { from, to, text },
+ action,
+ tone,
+ modelName: modelName ?? undefined,
+ surroundingContext: {
+ before: docText.substring(Math.max(0, from - 400), from),
+ after: docText.substring(to, Math.min(docSize, to + 400)),
+ },
+ followUpInstruction: instruction,
+ previousResult: prevResult,
+ };
+
+ setStatus("loading");
+ setResult(null);
+ setThinking(null);
+ setError(null);
+ await runStream(req, abort);
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [writingSelection, threadId, modelName, editor, result, runStream],
+ );
+
+ const reset = useCallback(() => {
+ abortRef.current?.abort();
+ abortRef.current = null;
+ setStatus("idle");
+ setResult(null);
+ setThinking(null);
+ setError(null);
+ }, []);
+
+ return { status, result, thinking, error, rewrite, rewriteFollowUp, reset };
+}
diff --git a/frontend-web/src/open-canvas/types/selection.ts b/frontend-web/src/open-canvas/types/selection.ts
index ae46d14d..b3abcd49 100644
--- a/frontend-web/src/open-canvas/types/selection.ts
+++ b/frontend-web/src/open-canvas/types/selection.ts
@@ -13,3 +13,10 @@ export type CanvasSelectionState = {
textSelection: CanvasTextSelection | null;
codeSelection: CanvasCodeSelection | null;
};
+
+/** Tiptap editor selection — used by AI rewrite flow */
+export type WritingSelection = {
+ from: number;
+ to: number;
+ text: string;
+};
diff --git a/frontend-web/src/open-canvas/utils/apply-rewrite.ts b/frontend-web/src/open-canvas/utils/apply-rewrite.ts
new file mode 100644
index 00000000..ba9c7b9d
--- /dev/null
+++ b/frontend-web/src/open-canvas/utils/apply-rewrite.ts
@@ -0,0 +1,51 @@
+import type { Editor } from "@tiptap/react";
+import type { WritingSelection } from "../types/selection";
+
+/** Convert plain text (possibly multi-paragraph) to a list of Tiptap paragraph nodes. */
+function textToNodes(schema: Editor["schema"], text: string) {
+ const paragraphs = text.split(/\n{2,}/);
+ return paragraphs
+ .map((p) => p.trim())
+ .filter(Boolean)
+ .map((p) => ({
+ type: "paragraph",
+ content: p ? [{ type: "text", text: p }] : [],
+ }));
+}
+
+/**
+ * Replace the selection range with the new text.
+ * Restores focus and places cursor after the inserted content.
+ */
+export function applyReplace(
+ editor: Editor,
+ selection: WritingSelection,
+ newText: string,
+): void {
+ const { from, to } = selection;
+ const nodes = textToNodes(editor.schema, newText);
+ if (nodes.length === 0) return;
+
+ editor
+ .chain()
+ .focus()
+ .deleteRange({ from, to })
+ .insertContentAt(from, nodes)
+ .run();
+}
+
+/**
+ * Insert the new text as new paragraph(s) after the selection end.
+ * Original text is preserved.
+ */
+export function applyInsert(
+ editor: Editor,
+ selection: WritingSelection,
+ newText: string,
+): void {
+ const { to } = selection;
+ const nodes = textToNodes(editor.schema, newText);
+ if (nodes.length === 0) return;
+
+ editor.chain().focus().insertContentAt(to, nodes).run();
+}
diff --git a/frontend-web/src/open-canvas/utils/quick-actions.ts b/frontend-web/src/open-canvas/utils/quick-actions.ts
index ca10d465..6cab60bb 100644
--- a/frontend-web/src/open-canvas/utils/quick-actions.ts
+++ b/frontend-web/src/open-canvas/utils/quick-actions.ts
@@ -1,16 +1,34 @@
+import type { CanvasArtifactContent } from "../types/artifact";
import type { QuickActionId } from "../types/quick-action";
+import { isCodeArtifact } from "./artifact";
const QUICK_ACTION_MESSAGES: Record = {
- expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
- shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
- formalize: "请将当前内容改写为正式、专业的语气",
- casualize: "请将当前内容改写为口语化、友好的语气",
- add_comments: "请为当前代码添加清晰实用的注释",
- add_logs: "请为当前代码在关键位置添加适当的日志语句",
- fix_bugs: "请检查并修复当前代码中的所有 Bug",
+ expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
+ shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
+ formalize: "请将当前内容改写为正式、专业的语气",
+ casualize: "请将当前内容改写为口语化、友好的语气",
+ add_comments: "请为当前代码添加清晰实用的注释",
+ add_logs: "请为当前代码在关键位置添加适当的日志语句",
+ fix_bugs: "请检查并修复当前代码中的所有 Bug",
optimize_code: "请对当前代码进行性能和可读性优化",
};
-export function buildQuickActionMessage(id: QuickActionId): string {
- return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
+/**
+ * Builds the human message for a quick action.
+ * The current artifact content is always included so the agent knows what to edit —
+ * LangGraph's create_agent only passes state.messages to the LLM, not state.artifact.
+ */
+export function buildQuickActionMessage(
+ id: QuickActionId,
+ content: CanvasArtifactContent,
+): string {
+ const instruction = QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
+ const artifactContent = isCodeArtifact(content) ? content.code : content.fullMarkdown;
+ const tag = isCodeArtifact(content) ? `language="${content.language}"` : "";
+
+ return `${instruction}
+
+
+${artifactContent}
+`;
}
diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css
index 8c4e042a..c7d0036d 100644
--- a/frontend-web/src/styles/globals.css
+++ b/frontend-web/src/styles/globals.css
@@ -566,6 +566,180 @@
}
}
+/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
+.tiptap-writing-editor .ProseMirror {
+ outline: none;
+ min-height: 320px;
+ caret-color: var(--primary);
+ padding-bottom: 6rem; /* ensure last paragraph isn't flush against the bottom */
+}
+
+.tiptap-writing-editor .ProseMirror p {
+ margin-bottom: 1rem;
+ line-height: 1.8;
+ font-size: 0.9375rem; /* 15px */
+ color: var(--foreground);
+}
+
+.tiptap-writing-editor .ProseMirror h1 {
+ font-size: 1.625rem;
+ font-weight: 700;
+ letter-spacing: -0.025em;
+ margin-top: 2rem;
+ margin-bottom: 0.75rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border);
+ color: var(--foreground);
+ line-height: 1.25;
+}
+
+.tiptap-writing-editor .ProseMirror h1:first-child {
+ margin-top: 0;
+}
+
+.tiptap-writing-editor .ProseMirror h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ margin-top: 1.75rem;
+ margin-bottom: 0.625rem;
+ color: var(--foreground);
+ line-height: 1.35;
+}
+
+.tiptap-writing-editor .ProseMirror h3 {
+ font-size: 1.0625rem;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin-top: 1.25rem;
+ margin-bottom: 0.5rem;
+ color: var(--foreground);
+ line-height: 1.4;
+}
+
+.tiptap-writing-editor .ProseMirror h2:first-child,
+.tiptap-writing-editor .ProseMirror h3:first-child {
+ margin-top: 0;
+}
+
+.tiptap-writing-editor .ProseMirror strong {
+ font-weight: 600;
+}
+
+.tiptap-writing-editor .ProseMirror em {
+ font-style: italic;
+}
+
+.tiptap-writing-editor .ProseMirror s {
+ text-decoration: line-through;
+ opacity: 0.6;
+}
+
+.tiptap-writing-editor .ProseMirror ul {
+ list-style-type: disc;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ font-size: 0.9375rem;
+ color: var(--foreground);
+}
+
+.tiptap-writing-editor .ProseMirror ol {
+ list-style-type: decimal;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ font-size: 0.9375rem;
+ color: var(--foreground);
+}
+
+.tiptap-writing-editor .ProseMirror li {
+ line-height: 1.8;
+ margin-bottom: 0.25rem;
+}
+
+.tiptap-writing-editor .ProseMirror li > p {
+ margin-bottom: 0.25rem;
+}
+
+.tiptap-writing-editor .ProseMirror blockquote {
+ border-left: 3px solid var(--border);
+ padding-left: 1rem;
+ margin: 1.25rem 0;
+ font-style: italic;
+ font-size: 0.9375rem;
+ color: var(--muted-foreground);
+}
+
+.tiptap-writing-editor .ProseMirror pre {
+ background: var(--muted);
+ border-radius: 0.5rem;
+ padding: 1rem 1.25rem;
+ margin: 1rem 0 1.25rem;
+ overflow-x: auto;
+ border: 1px solid var(--border);
+}
+
+.tiptap-writing-editor .ProseMirror pre code {
+ background: transparent;
+ padding: 0;
+ font-size: 0.8125rem; /* 13px */
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
+ color: var(--foreground);
+ line-height: 1.7;
+}
+
+.tiptap-writing-editor .ProseMirror code {
+ background: var(--muted);
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ font-size: 0.8125rem;
+ font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
+ color: var(--foreground);
+ border: 1px solid var(--border);
+}
+
+.tiptap-writing-editor .ProseMirror hr {
+ border: none;
+ border-top: 1px solid var(--border);
+ margin: 2rem 0;
+}
+
+.tiptap-writing-editor .ProseMirror a {
+ color: var(--primary);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ text-decoration-thickness: 1px;
+ transition: opacity 0.15s;
+}
+
+.tiptap-writing-editor .ProseMirror a:hover {
+ opacity: 0.75;
+}
+
+/* Placeholder */
+.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child::before {
+ content: attr(data-placeholder);
+ color: var(--muted-foreground);
+ pointer-events: none;
+ float: left;
+ height: 0;
+ opacity: 0.6;
+}
+
+/* Selection highlight — use color-mix to avoid the background+opacity mismatch */
+.tiptap-writing-editor .ProseMirror ::selection {
+ background: color-mix(in srgb, #f87171 35%, transparent);
+}
+
+/* AI rewrite — highlight the selected range while the menu / preview is open */
+.ai-rewrite-highlight {
+ background: color-mix(in srgb, #fbbf24 30%, transparent);
+ border-radius: 2px;
+ transition: background 0.2s ease;
+}
+.dark .ai-rewrite-highlight {
+ background: color-mix(in srgb, #fbbf24 18%, transparent);
+}
+
:root {
--container-width-xs: calc(var(--spacing) * 72);
--container-width-sm: calc(var(--spacing) * 144);
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index 2480abab..1c645ce7 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -43,6 +43,7 @@ from app.gateway.routers import (
thread_runs,
threads,
uploads,
+ writing,
)
from deerflow.config import app_config as deerflow_app_config
from deerflow.config.app_config import apply_logging_level
@@ -671,6 +672,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
+ # Writing rewrite API is mounted at /api/writing
+ app.include_router(writing.router)
+
# Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/__init__.py b/offline-backend-20260512/backend/app/gateway/routers/__init__.py
index 4223efc2..5b49d975 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/__init__.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/__init__.py
@@ -1,3 +1,3 @@
-from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads
+from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads, writing
-__all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads"]
+__all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads", "writing"]
diff --git a/offline-backend-20260512/backend/app/gateway/routers/writing.py b/offline-backend-20260512/backend/app/gateway/routers/writing.py
new file mode 100644
index 00000000..6380b88b
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/writing.py
@@ -0,0 +1,191 @@
+"""Writing rewrite API — SSE streaming endpoint.
+
+POST /api/writing/rewrite
+ Returns text/event-stream with frames:
+ event: thinking data: {"chunk": "..."}
+ event: text data: {"chunk": "..."}
+ event: done data: {}
+ event: error data: {"message": "..."}
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+
+from fastapi import APIRouter, Depends
+from fastapi.responses import StreamingResponse
+from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
+from pydantic import BaseModel, Field
+
+from app.gateway.deps import get_config
+from deerflow.config.app_config import AppConfig
+from deerflow.models import create_chat_model
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api/writing", tags=["writing"])
+
+# ── Prompts ───────────────────────────────────────────────────────────────────
+
+_SYSTEM_PROMPT = """你是一位专业的中文写作助手,擅长润色、扩写、缩写和调整文风。
+
+规则:
+- 只输出改写后的正文,不加任何前缀、解释或括号说明。
+- 保持原文的语言(中文输入 → 中文输出,英文输入 → 英文输出)。
+- 改写结果应与原文长度相近,除非任务要求扩写或缩写。
+- 不要添加 markdown 标题或特殊格式,除非原文本身包含。
+"""
+
+_ACTION_INSTRUCTIONS: dict[str, str] = {
+ "polish": "请对以下文字进行润色,使其表达更流畅、自然,同时保留原意。",
+ "expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。",
+ "shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。",
+ "tone": "请调整以下文字的语气风格。",
+}
+
+_TONE_SUFFIX: dict[str, str] = {
+ "professional": "目标风格:专业、正式。",
+ "casual": "目标风格:轻松、口语化。",
+ "readable": "目标风格:通俗易懂、易于阅读。",
+ "academic": "目标风格:学术严谨、逻辑清晰。",
+ "expressive": "目标风格:生动有文采、富有感染力。",
+}
+
+
+# ── Request model ─────────────────────────────────────────────────────────────
+
+
+class SelectionInfo(BaseModel):
+ from_: int = Field(..., alias="from", description="ProseMirror start offset")
+ to: int = Field(..., description="ProseMirror end offset")
+ text: str = Field(..., description="Selected plain text")
+
+ model_config = {"populate_by_name": True}
+
+
+class SurroundingContext(BaseModel):
+ before: str = Field(default="", description="Text before the selection")
+ after: str = Field(default="", description="Text after the selection")
+
+
+class RewriteRequest(BaseModel):
+ thread_id: str = Field(..., alias="threadId")
+ selection: SelectionInfo
+ action: str = Field(..., description="polish | expand | shorten | tone")
+ tone: str | None = Field(default=None)
+ model_name: str | None = Field(default=None, alias="modelName")
+ surrounding_context: SurroundingContext = Field(default_factory=SurroundingContext, alias="surroundingContext")
+ custom_instruction: str | None = Field(default=None, alias="customInstruction")
+ follow_up_instruction: str | None = Field(default=None, alias="followUpInstruction")
+ previous_result: str | None = Field(default=None, alias="previousResult")
+
+ model_config = {"populate_by_name": True}
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+
+def _sse(event: str, data: dict) -> str:
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+
+def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
+ """Return (thinking_delta, text_delta) from a LangChain streaming chunk."""
+ thinking = ""
+ text = ""
+
+ content = getattr(chunk, "content", "")
+ if isinstance(content, str):
+ text = content
+ elif isinstance(content, list):
+ for block in content:
+ if isinstance(block, str):
+ text += block
+ elif isinstance(block, dict):
+ btype = block.get("type", "")
+ if btype in ("thinking", "thinking_delta"):
+ thinking += block.get("thinking", "")
+ elif btype in ("text", "text_delta"):
+ text += block.get("text", "")
+ elif btype == "reasoning":
+ thinking += block.get("content", "")
+
+ # DeepSeek / OpenAI o-series: reasoning in additional_kwargs
+ if not thinking:
+ kwargs = getattr(chunk, "additional_kwargs", {}) or {}
+ r = kwargs.get("reasoning_content") or ""
+ if isinstance(r, str):
+ thinking = r
+
+ return thinking, text
+
+
+# ── Endpoint ──────────────────────────────────────────────────────────────────
+
+
+@router.post(
+ "/rewrite",
+ summary="AI Text Rewrite (SSE stream)",
+ response_class=StreamingResponse,
+)
+async def rewrite_selection(
+ body: RewriteRequest,
+ config: AppConfig = Depends(get_config),
+) -> StreamingResponse:
+ action = body.action if body.action in _ACTION_INSTRUCTIONS else "polish"
+ instruction = _ACTION_INSTRUCTIONS[action]
+ if action == "tone" and body.tone and body.tone in _TONE_SUFFIX:
+ instruction = f"{instruction}\n{_TONE_SUFFIX[body.tone]}"
+
+ context_parts: list[str] = []
+ before = body.surrounding_context.before.strip()
+ after = body.surrounding_context.after.strip()
+ if before or after:
+ context_parts.append("文章上下文(仅供参考,不要改写):")
+ if before:
+ context_parts.append(f"[前文] {before[-300:]}")
+ if after:
+ context_parts.append(f"[后文] {after[:300]}")
+ context_parts.append("")
+
+ context_block = "\n".join(context_parts)
+ base_instruction = instruction
+ if body.custom_instruction:
+ base_instruction = f"{instruction}\n用户额外要求:{body.custom_instruction}"
+ user_message = f"{base_instruction}\n\n{context_block}需要改写的文字:\n{body.selection.text}"
+
+ if body.follow_up_instruction and body.previous_result:
+ messages = [
+ SystemMessage(content=_SYSTEM_PROMPT),
+ HumanMessage(content=user_message),
+ AIMessage(content=body.previous_result),
+ HumanMessage(content=body.follow_up_instruction),
+ ]
+ else:
+ messages = [SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=user_message)]
+
+ # Auto-enable thinking when the selected model supports it
+ resolved_name = body.model_name or (config.models[0].name if config.models else None)
+ model_cfg = config.get_model_config(resolved_name) if resolved_name else None
+ thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
+
+ async def generate():
+ try:
+ model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
+ async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
+ thinking_delta, text_delta = _extract_chunk_parts(chunk)
+ if thinking_delta:
+ yield _sse("thinking", {"chunk": thinking_delta})
+ if text_delta:
+ yield _sse("text", {"chunk": text_delta})
+ yield _sse("done", {})
+ except Exception as e:
+ logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
+ yield _sse("error", {"message": str(e)})
+
+ return StreamingResponse(
+ generate(),
+ media_type="text/event-stream",
+ headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py
index bd003e5e..3eb702f2 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py
@@ -24,6 +24,48 @@ Rules:
- If the user is only asking a question (no edit needed), answer in text — skip the tool call.
+
+Before creating a brand-new artifact or performing a major rewrite, verify you have enough
+information to do the job well. When key details are missing, call `ask_clarification` FIRST
+— do NOT start generating the artifact and ask questions mid-way.
+
+**MUST clarify when (no existing artifact or major rewrite requested):**
+1. OUTPUT TYPE IS VAGUE
+ - "帮我做个主页" / "make a homepage" → ask: tech stack (plain HTML / React / Vue?),
+ design style, which sections to include
+ - "写个报告" / "write a report" → ask: topic, target audience, expected length,
+ required sections
+ - "写个脚本" / "write a script" → ask: programming language, what it should do
+2. REQUIRED CONTENT IS MISSING
+ - User wants a personal profile page but hasn't provided their name / bio / links
+ - User wants a data table but hasn't provided the data or schema
+3. AMBIGUOUS APPROACH WITH SIGNIFICANTLY DIFFERENT OUTCOMES
+ - "Design a chat system" → ask: client–server or peer-to-peer, persistence needed?
+ - "Write an intro" → ask: formal essay or casual blog post?
+
+**DO NOT clarify for:**
+- Simple edits to an existing artifact: "make it bold", "fix the typo", "shorten paragraph 2"
+- Formatting / style tweaks: "add comments", "use camelCase", "format the code"
+- Quick actions: expand / shorten / formalize / casualize / add_comments / fix_bugs / optimize_code
+- Requests where intent is clear from context (e.g., user pastes content and says "translate this")
+- Follow-up turns where enough context already exists in the conversation
+
+**Format for clarification (use options when practical):**
+ask_clarification(
+ question="你希望这个主页使用什么技术栈?",
+ clarification_type="missing_info",
+ options=[
+ {{"id": "html", "label": "纯 HTML / CSS / JS", "description": "无框架,最简单"}},
+ {{"id": "react", "label": "React", "description": "组件化,适合复杂交互"}},
+ {{"id": "vue", "label": "Vue", "description": "渐进式框架,上手容易"}},
+ ],
+ allow_custom=True,
+)
+
+CRITICAL: clarification ALWAYS comes before generating the artifact. Never produce a partial
+artifact and then ask for clarification in the same response.
+
+
The current artifact state is injected into your context. When editing, build on the existing content exactly.
When creating from scratch, produce a complete, high-quality artifact in one shot.