智能选中文字
This commit is contained in:
1 parent
e8a31b5d37
commit
da4a08dfe1
34 files changed
+3208
-541
No files matched your search
@@ -28,6 +28,7 @@ import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
parseUploadedFiles,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
@@ -173,7 +174,7 @@ function MessageContent_({
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent ? stripUploadedFilesTag(rawContent) : "";
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getCanvasLangGraphBaseURL } from "../config";
|
||||
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
/**
|
||||
* Dedicated LangGraph client for the Canvas page.
|
||||
*
|
||||
* Keeping this separate from getAPIClient() means:
|
||||
* - Canvas API calls can be pointed at a different server via VITE_CANVAS_LANGGRAPH_BASE_URL
|
||||
* - Canvas-specific HTTP-level logic (headers, retries, middleware) lives here
|
||||
* - Changes here never touch the main page's API path
|
||||
*/
|
||||
function createCanvasClient(): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getCanvasLangGraphBaseURL(),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return { ...init, headers };
|
||||
},
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
client.runs.stream = ((threadId, assistantId, payload) =>
|
||||
originalRunStream(
|
||||
threadId,
|
||||
assistantId,
|
||||
sanitizeRunStreamOptions(payload),
|
||||
)) as typeof client.runs.stream;
|
||||
|
||||
const originalJoinStream = client.runs.joinStream.bind(client.runs);
|
||||
client.runs.joinStream = ((threadId, runId, options) =>
|
||||
originalJoinStream(
|
||||
threadId,
|
||||
runId,
|
||||
sanitizeRunStreamOptions(options),
|
||||
)) as typeof client.runs.joinStream;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
let _canvasClient: LangGraphClient | null = null;
|
||||
|
||||
export function getCanvasAPIClient(): LangGraphClient {
|
||||
if (!_canvasClient) {
|
||||
_canvasClient = createCanvasClient();
|
||||
}
|
||||
return _canvasClient;
|
||||
}
|
||||
@@ -18,6 +18,23 @@ export function getBackendBaseURL() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas page has its own LangGraph base URL so canvas-specific modifications
|
||||
* (different server, different route prefix, etc.) can be made independently
|
||||
* without touching the main page's API path.
|
||||
* Falls back to the main LangGraph URL when VITE_CANVAS_LANGGRAPH_BASE_URL is unset.
|
||||
*/
|
||||
export function getCanvasLangGraphBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL) {
|
||||
return new URL(
|
||||
env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL,
|
||||
getBaseOrigin(),
|
||||
).toString();
|
||||
}
|
||||
// Fall back to the main LangGraph URL (same server, different assistantId)
|
||||
return getLangGraphBaseURL();
|
||||
}
|
||||
|
||||
export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
|
||||
return new URL(
|
||||
|
||||
@@ -14,20 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
|
||||
|
||||
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
|
||||
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
| HumanMessageGroup
|
||||
| AssistantProcessingGroup
|
||||
| AssistantMessageGroup
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
messages: Message[],
|
||||
@@ -44,11 +44,11 @@ export function groupMessages<T>(
|
||||
function lastOpenGroup() {
|
||||
const last = groups[groups.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -70,23 +70,23 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
if (message.type === "tool") {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
// Add to the preceding processing group to preserve tool-call association,
|
||||
// then also open a standalone clarification group for prominent display.
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:clarification",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
const open = lastOpenGroup();
|
||||
if (open) {
|
||||
open.messages.push(message);
|
||||
@@ -297,19 +297,19 @@ export function hasPresentFiles(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_agent" &&
|
||||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||||
message.additional_kwargs.setup_agent_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_agent" &&
|
||||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||||
message.additional_kwargs.setup_agent_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
@@ -372,6 +372,13 @@ export function stripUploadedFilesTag(content: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Strip injected <artifact ...>...</artifact> blocks from human message display. */
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content
|
||||
.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseUploadedFiles(content: string): FileInMessage[] {
|
||||
// Match <uploaded_files>...</uploaded_files> tag
|
||||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||||
|
||||
@@ -4,6 +4,7 @@ function readEnv(
|
||||
key:
|
||||
| "VITE_BACKEND_BASE_URL"
|
||||
| "VITE_LANGGRAPH_BASE_URL"
|
||||
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
|
||||
| "VITE_STATIC_WEBSITE_ONLY"
|
||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||
): EnvValue {
|
||||
@@ -14,6 +15,9 @@ function readEnv(
|
||||
export const env = {
|
||||
NEXT_PUBLIC_BACKEND_BASE_URL: readEnv("VITE_BACKEND_BASE_URL"),
|
||||
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readEnv("VITE_LANGGRAPH_BASE_URL"),
|
||||
// Canvas page uses its own LangGraph base URL so it can be independently
|
||||
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
|
||||
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { env } from "@/env";
|
||||
|
||||
const BASE = (env.NEXT_PUBLIC_BACKEND_BASE_URL as string | undefined) ?? "http://localhost:8001";
|
||||
|
||||
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
|
||||
export type RewriteTone =
|
||||
| "professional"
|
||||
| "casual"
|
||||
| "readable"
|
||||
| "academic"
|
||||
| "expressive";
|
||||
|
||||
export interface RewriteRequest {
|
||||
threadId: string;
|
||||
selection: { from: number; to: number; text: string };
|
||||
action: RewriteAction;
|
||||
tone?: RewriteTone;
|
||||
modelName?: string;
|
||||
surroundingContext?: { before?: string; after?: string };
|
||||
customInstruction?: string;
|
||||
followUpInstruction?: string;
|
||||
previousResult?: string;
|
||||
}
|
||||
|
||||
export type RewriteStreamEvent =
|
||||
| { type: "thinking"; chunk: string }
|
||||
| { type: "text"; chunk: string }
|
||||
| { type: "done" }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
// ── SSE parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let currentEvent = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed.startsWith("event: ")) {
|
||||
currentEvent = trimmed.slice(7);
|
||||
} else if (trimmed.startsWith("data: ")) {
|
||||
yield { event: currentEvent, data: trimmed.slice(6) };
|
||||
currentEvent = "";
|
||||
}
|
||||
// empty line resets event field (SSE spec)
|
||||
else if (trimmed === "") {
|
||||
currentEvent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming entry point ─────────────────────────────────────────────────────
|
||||
|
||||
export async function* streamRewrite(
|
||||
req: RewriteRequest,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<RewriteStreamEvent> {
|
||||
const res = await apiFetch(`${BASE}/api/writing/rewrite`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
|
||||
const msg = (body?.detail as string | undefined) ?? (body?.message as string | undefined) ?? `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (!res.body) throw new Error("No response body");
|
||||
|
||||
for await (const { event, data } of parseSse(res.body)) {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
if (event === "thinking") {
|
||||
yield { type: "thinking", chunk: parsed.chunk as string };
|
||||
} else if (event === "text") {
|
||||
yield { type: "text", chunk: parsed.chunk as string };
|
||||
} else if (event === "done") {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
} else if (event === "error") {
|
||||
yield { type: "error", message: parsed.message as string };
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed frame
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,76 @@ import remarkGfm from "remark-gfm";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CanvasArtifactText } from "../../types/artifact";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
import { TiptapEditor } from "../../editor/TiptapEditor";
|
||||
import { useWritingEditor } from "../../hooks/useWritingEditor";
|
||||
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
|
||||
import { WritingToolbar } from "./WritingToolbar";
|
||||
import { WritingBubbleMenu } from "./WritingBubbleMenu";
|
||||
import { WritingParagraphMenu } from "./WritingParagraphMenu";
|
||||
import { WritingAiMenu } from "./WritingAiMenu";
|
||||
import { WritingRewritePreview } from "./WritingRewritePreview";
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { applyReplace, applyInsert } from "../../utils/apply-rewrite";
|
||||
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
||||
|
||||
interface TextArtifactRendererProps {
|
||||
content: CanvasArtifactText;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface PanelPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Phase of the AI rewrite flow */
|
||||
type FlowPhase = "idle" | "menu" | "preview";
|
||||
|
||||
interface LastAction {
|
||||
action: RewriteAction;
|
||||
tone?: RewriteTone;
|
||||
customInstruction?: string;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
||||
const { handleTextPointerUp } = useCanvasSelection();
|
||||
const { artifact, setArtifact } = useCanvasGraphContext();
|
||||
const { writingSelection } = useCanvasUIContext();
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
// Auto-scroll to bottom while streaming, unless user has scrolled up
|
||||
// ── AI rewrite flow state ──────────────────────────────────────────────
|
||||
const [phase, setPhase] = useState<FlowPhase>("idle");
|
||||
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
|
||||
const lastActionRef = useRef<LastAction | null>(null);
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(markdown: string) => {
|
||||
if (!artifact) return;
|
||||
setArtifact({
|
||||
...artifact,
|
||||
contents: artifact.contents.map((c) =>
|
||||
c.index === artifact.currentIndex && c.type === "text"
|
||||
? { ...c, fullMarkdown: markdown }
|
||||
: c,
|
||||
),
|
||||
});
|
||||
},
|
||||
[artifact, setArtifact],
|
||||
);
|
||||
|
||||
const editor = useWritingEditor({
|
||||
markdown: content.fullMarkdown,
|
||||
onChange: handleEditorChange,
|
||||
editable: !isStreaming,
|
||||
placeholder: "开始写作…",
|
||||
});
|
||||
|
||||
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } = useWritingRewrite(editor);
|
||||
|
||||
// ── Streaming auto-scroll ──────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
autoScrollRef.current = true;
|
||||
@@ -46,7 +102,70 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
setShowScrollBtn(false);
|
||||
}, []);
|
||||
|
||||
if (!content.fullMarkdown) {
|
||||
// ── AI rewrite flow handlers ───────────────────────────────────────────
|
||||
|
||||
/** Compute anchor position from current DOM selection */
|
||||
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) => {
|
||||
lastActionRef.current = { action, tone, customInstruction };
|
||||
rewrite(action, tone, customInstruction);
|
||||
setPhase("preview");
|
||||
},
|
||||
[rewrite],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (!lastActionRef.current) return;
|
||||
const { action, tone, customInstruction } = lastActionRef.current;
|
||||
rewrite(action, tone, customInstruction);
|
||||
}, [rewrite]);
|
||||
|
||||
const handleFollowUp = useCallback(
|
||||
(instruction: string) => {
|
||||
if (!lastActionRef.current) return;
|
||||
const { action, tone } = lastActionRef.current;
|
||||
rewriteFollowUp(instruction, action, tone);
|
||||
},
|
||||
[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]);
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────────
|
||||
if (!content.fullMarkdown && !isStreaming) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -56,142 +175,145 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full overflow-y-auto"
|
||||
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-8 py-8">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-7 text-sm text-foreground/90">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-7">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
if (isBlock) {
|
||||
return (
|
||||
// ── Streaming mode ────────────────────────────────────────────────────
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<div className="relative h-full flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-[1.8] text-[15px] text-foreground">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-7">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
return isBlock ? (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="mb-4 overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted/50">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
hr: () => <hr className="my-6 border-border" />,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-foreground/80">{children}</em>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollBtn && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="pointer-events-auto shadow-md gap-1.5"
|
||||
onClick={handleScrollToBottom}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
回到底部
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<WritingToolbar editor={editor} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-10 pt-10">
|
||||
{editor && (
|
||||
<>
|
||||
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||
</>
|
||||
)}
|
||||
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isStreaming && showScrollBtn && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="pointer-events-auto shadow-md gap-1.5"
|
||||
onClick={handleScrollToBottom}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
回到底部
|
||||
</Button>
|
||||
</div>
|
||||
{/* Phase: AI action menu */}
|
||||
{phase === "menu" && (
|
||||
<WritingAiMenu
|
||||
pos={panelPos}
|
||||
status={status}
|
||||
error={error}
|
||||
onAction={handleAction}
|
||||
onAbort={reset}
|
||||
onRetry={handleRetry}
|
||||
onClose={closeAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Phase: result preview */}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Sparkles,
|
||||
Wand2,
|
||||
Expand,
|
||||
Shrink,
|
||||
Mic2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
X,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
||||
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MenuPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
// ── Tone options ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TONE_OPTIONS: { value: RewriteTone; label: string }[] = [
|
||||
{ value: "professional", label: "更专业" },
|
||||
{ value: "casual", label: "更口语化" },
|
||||
{ value: "readable", label: "更易读" },
|
||||
{ value: "academic", label: "更学术" },
|
||||
{ value: "expressive", label: "更有文采" },
|
||||
];
|
||||
|
||||
// ── Action rows ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTION_ROWS: {
|
||||
action: RewriteAction;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}[] = [
|
||||
{ action: "polish", label: "润色", icon: Wand2 },
|
||||
{ action: "expand", label: "扩写", icon: Expand },
|
||||
{ action: "shorten", label: "缩写", icon: Shrink },
|
||||
{ action: "tone", label: "改语气", icon: Mic2 },
|
||||
];
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface WritingAiMenuProps {
|
||||
pos: MenuPos;
|
||||
status: RewriteStatus;
|
||||
error: string | null;
|
||||
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => void;
|
||||
onAbort: () => void;
|
||||
onRetry: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WritingAiMenu({
|
||||
pos,
|
||||
status,
|
||||
error,
|
||||
onAction,
|
||||
onAbort,
|
||||
onRetry,
|
||||
onClose,
|
||||
}: WritingAiMenuProps) {
|
||||
const [toneOpen, setToneOpen] = useState(false);
|
||||
const [instruction, setInstruction] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// autoFocus is unreliable when the menu opens during a mousedown event sequence;
|
||||
// explicit focus via setTimeout(0) runs after the browser finishes that sequence.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
|
||||
const isLoading = status === "loading";
|
||||
const isError = status === "error";
|
||||
|
||||
// Measure actual height after render and compute safe position before browser paint
|
||||
const menuWidth = 240;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const h = el.getBoundingClientRect().height;
|
||||
const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - menuWidth - 8);
|
||||
const spaceBelow = window.innerHeight - pos.y - 8;
|
||||
const spaceAbove = pos.y - 8;
|
||||
let safeY: number;
|
||||
if (spaceBelow >= h) {
|
||||
safeY = pos.y + 4;
|
||||
} else if (spaceAbove >= h) {
|
||||
safeY = pos.y - h - 4;
|
||||
} else if (spaceBelow >= spaceAbove) {
|
||||
safeY = Math.max(8, window.innerHeight - h - 8);
|
||||
} else {
|
||||
safeY = 8;
|
||||
}
|
||||
setComputedPos({ left: safeX, top: safeY });
|
||||
}, [pos.x, pos.y]);
|
||||
|
||||
const fireAction = (action: RewriteAction, tone?: RewriteTone) => {
|
||||
onAction(action, tone, instruction.trim() || undefined);
|
||||
};
|
||||
|
||||
const handleInputEnter = () => {
|
||||
const trimmed = instruction.trim();
|
||||
onAction("polish", undefined, trimmed || undefined);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: computedPos?.left ?? pos.x,
|
||||
top: computedPos?.top ?? (pos.y + 4),
|
||||
width: menuWidth,
|
||||
zIndex: 9999,
|
||||
visibility: computedPos ? "visible" : "hidden",
|
||||
}}
|
||||
className="rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
|
||||
AI 改写
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Loading state ──────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center gap-4 px-4 py-6">
|
||||
{/* Bouncing dots */}
|
||||
<div className="flex items-center gap-2">
|
||||
{[0, 160, 320].map((delay) => (
|
||||
<span
|
||||
key={delay}
|
||||
className="h-1.5 w-1.5 rounded-full bg-primary animate-bouncing"
|
||||
style={{ animationDelay: `${delay}ms` }}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-1 text-xs text-muted-foreground">AI 正在生成…</span>
|
||||
</div>
|
||||
|
||||
{/* Stop button — breathing animation */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className={cn(
|
||||
"animate-breathe-stop",
|
||||
"flex items-center gap-1.5 px-5 py-2 text-xs font-medium rounded-lg",
|
||||
"border border-red-300/70 bg-red-50/60 text-red-600",
|
||||
"dark:border-red-800/50 dark:bg-red-950/20 dark:text-red-400",
|
||||
"hover:bg-red-100/90 dark:hover:bg-red-950/40 transition-colors",
|
||||
)}
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
停止生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Error state ─────────────────────────────────────────── */}
|
||||
{isError && (
|
||||
<div className="px-3 py-3 space-y-2">
|
||||
<p className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
{error ?? "改写失败"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:underline"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Idle state: input + action list ─────────────────────── */}
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{/* Custom instruction input */}
|
||||
<div className="px-2.5 pt-2.5 pb-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleInputEnter();
|
||||
}
|
||||
}}
|
||||
placeholder="告诉 AI 你想要什么…"
|
||||
className={cn(
|
||||
"w-full text-xs rounded-md px-2.5 py-1.5 outline-none transition-colors",
|
||||
"bg-muted/50 border border-input focus:border-primary/60",
|
||||
"placeholder:text-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/50 mx-1" />
|
||||
|
||||
{/* Preset actions */}
|
||||
<div className="p-1 space-y-0.5">
|
||||
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
|
||||
const isTone = action === "tone";
|
||||
const isTonesOpen = isTone && toneOpen;
|
||||
return (
|
||||
<div key={action}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isTone) {
|
||||
setToneOpen((v) => !v);
|
||||
} else {
|
||||
fireAction(action);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isTonesOpen && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{isTone && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
|
||||
isTonesOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tone sub-list */}
|
||||
{isTonesOpen && (
|
||||
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
|
||||
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => fireAction("tone", value)}
|
||||
className="flex w-full items-center px-2 py-1.5 text-xs rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
{toneLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import {
|
||||
Sparkles,
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Heading1,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number; // top of selection
|
||||
bottom: number; // bottom of selection (used to flip menu below when near top)
|
||||
}
|
||||
|
||||
// ── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
interface BubbleButtonProps {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function BubbleButton({ onClick, active, className, children }: BubbleButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // keep editor focused
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 px-2 text-xs font-medium rounded transition-colors",
|
||||
"hover:bg-black/8 dark:hover:bg-white/10",
|
||||
active && "bg-black/10 dark:bg-white/15",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function BubbleSep() {
|
||||
return <div className="w-px h-4 bg-black/15 dark:bg-white/25 mx-0.5 shrink-0" />;
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface WritingBubbleMenuProps {
|
||||
editor: Editor;
|
||||
onAiRewrite: () => void;
|
||||
}
|
||||
|
||||
export function WritingBubbleMenu({ editor, onAiRewrite }: WritingBubbleMenuProps) {
|
||||
const { setWritingSelection } = useCanvasUIContext();
|
||||
const [position, setPosition] = useState<MenuPosition | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [computedStyle, setComputedStyle] = useState<React.CSSProperties>({
|
||||
position: "fixed", left: 0, top: 0, zIndex: 9999, visibility: "hidden",
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!position || !menuRef.current) return;
|
||||
const { width: mw, height: mh } = menuRef.current.getBoundingClientRect();
|
||||
// X: center on selection, clamp to viewport
|
||||
const rawLeft = position.x - mw / 2;
|
||||
const safeLeft = Math.max(8, Math.min(rawLeft, window.innerWidth - mw - 8));
|
||||
// Y: prefer above; flip below if too close to top
|
||||
let safeTop: number;
|
||||
if (position.y - mh - 8 >= 8) {
|
||||
safeTop = position.y - mh - 8;
|
||||
} else {
|
||||
safeTop = Math.min(position.bottom + 8, window.innerHeight - mh - 8);
|
||||
}
|
||||
setComputedStyle({ position: "fixed", left: safeLeft, top: safeTop, zIndex: 9999, visibility: "visible" });
|
||||
}, [position]);
|
||||
|
||||
// Track selection → compute menu position
|
||||
useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
|
||||
if (empty || editor.isActive("codeBlock")) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the native DOM selection for pixel coordinates
|
||||
const domSel = window.getSelection();
|
||||
if (!domSel || domSel.rangeCount === 0) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = domSel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (!rect.width && !rect.height) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store selection for AI rewrite
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
if (text.trim()) {
|
||||
setWritingSelection({ from, to, text });
|
||||
}
|
||||
|
||||
setPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
bottom: rect.bottom,
|
||||
});
|
||||
};
|
||||
|
||||
editor.on("selectionUpdate", updatePosition);
|
||||
editor.on("blur", () => setPosition(null));
|
||||
|
||||
return () => {
|
||||
editor.off("selectionUpdate", updatePosition);
|
||||
editor.off("blur", () => setPosition(null));
|
||||
};
|
||||
}, [editor, setWritingSelection]);
|
||||
|
||||
const handleAiRewrite = useCallback(() => {
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (!empty) {
|
||||
editor.commands.setAiHighlight(from, to);
|
||||
}
|
||||
onAiRewrite();
|
||||
}, [onAiRewrite, editor]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
const { from, to } = editor.state.selection;
|
||||
const text = editor.state.doc.textBetween(from, to, "\n");
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
if (!position) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={computedStyle}
|
||||
className="animate-in fade-in zoom-in-95 duration-100"
|
||||
>
|
||||
<div className="flex items-center gap-0 rounded-lg px-1 py-1 shadow-lg ring-1 ring-black/8 bg-popover text-foreground dark:bg-neutral-900 dark:ring-white/10">
|
||||
{/* AI 改写 — primary */}
|
||||
<BubbleButton
|
||||
onClick={handleAiRewrite}
|
||||
className="text-amber-500 hover:text-amber-600 font-semibold"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI 改写
|
||||
</BubbleButton>
|
||||
|
||||
<BubbleSep />
|
||||
|
||||
{/* Inline format */}
|
||||
<BubbleButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive("bold")}
|
||||
>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</BubbleButton>
|
||||
|
||||
<BubbleButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive("italic")}
|
||||
>
|
||||
<Italic className="h-3.5 w-3.5" />
|
||||
</BubbleButton>
|
||||
|
||||
<BubbleButton
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
active={editor.isActive("underline")}
|
||||
>
|
||||
<Underline className="h-3.5 w-3.5" />
|
||||
</BubbleButton>
|
||||
|
||||
<BubbleSep />
|
||||
|
||||
{/* Heading */}
|
||||
<BubbleButton
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
>
|
||||
<Heading1 className="h-3.5 w-3.5" />
|
||||
</BubbleButton>
|
||||
|
||||
<BubbleSep />
|
||||
|
||||
{/* Copy */}
|
||||
<BubbleButton onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</BubbleButton>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
|
||||
const REWRITABLE_TYPES = new Set(["paragraph", "heading", "blockquote"]);
|
||||
|
||||
const BTN_SIZE = 24;
|
||||
const BTN_GAP = 6; // gap between button right edge and text left edge
|
||||
// Extra zone around the button that still counts as "near" (bridges the gap)
|
||||
const HOVER_MARGIN = BTN_GAP + 8;
|
||||
// Delay before hiding the button (ms) — long enough to cross the gap at slow speed
|
||||
const HIDE_DELAY = 380;
|
||||
|
||||
interface HoverState {
|
||||
top: number;
|
||||
left: number;
|
||||
height: number;
|
||||
nodeFrom: number;
|
||||
nodeTo: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface WritingParagraphMenuProps {
|
||||
editor: Editor;
|
||||
onAiRewrite: () => void;
|
||||
}
|
||||
|
||||
export function WritingParagraphMenu({ editor, onAiRewrite }: WritingParagraphMenuProps) {
|
||||
const { setWritingSelection } = useCanvasUIContext();
|
||||
const [hover, setHover] = useState<HoverState | null>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Keep a ref in sync so the document-level handler always reads fresh state
|
||||
const hoverRef = useRef<HoverState | null>(null);
|
||||
hoverRef.current = hover;
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearHideTimer();
|
||||
hideTimerRef.current = setTimeout(() => setHover(null), HIDE_DELAY);
|
||||
}, [clearHideTimer]);
|
||||
|
||||
// Document-level mousemove: cancel hide when cursor is in the button's vicinity.
|
||||
// This bridges the gap between the editor's text edge and the button.
|
||||
useEffect(() => {
|
||||
const handleDocMove = (e: MouseEvent) => {
|
||||
const h = hoverRef.current;
|
||||
if (!h) return;
|
||||
|
||||
const btnLeft = h.left - BTN_SIZE - BTN_GAP;
|
||||
const btnTop = h.top + h.height / 2 - BTN_SIZE / 2;
|
||||
|
||||
const inZone =
|
||||
e.clientX >= btnLeft - HOVER_MARGIN &&
|
||||
e.clientX <= h.left + HOVER_MARGIN && // extend slightly into text side
|
||||
e.clientY >= btnTop - HOVER_MARGIN &&
|
||||
e.clientY <= btnTop + BTN_SIZE + HOVER_MARGIN;
|
||||
|
||||
if (inZone) clearHideTimer();
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleDocMove);
|
||||
return () => document.removeEventListener("mousemove", handleDocMove);
|
||||
}, [clearHideTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const editorDom = editor.view.dom as HTMLElement;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const pos = editor.view.posAtCoords({ left: e.clientX, top: e.clientY });
|
||||
if (!pos) { scheduleHide(); return; }
|
||||
|
||||
const $pos = editor.state.doc.resolve(pos.pos);
|
||||
const depth = Math.min(1, $pos.depth);
|
||||
if (depth === 0) { scheduleHide(); return; }
|
||||
|
||||
const nodeStart = $pos.before(depth);
|
||||
const node = editor.state.doc.nodeAt(nodeStart);
|
||||
if (!node || !REWRITABLE_TYPES.has(node.type.name)) {
|
||||
scheduleHide();
|
||||
return;
|
||||
}
|
||||
|
||||
const text = node.textContent.trim();
|
||||
if (!text) { scheduleHide(); return; }
|
||||
|
||||
const domNode = editor.view.nodeDOM(nodeStart);
|
||||
if (!domNode || !(domNode instanceof HTMLElement)) {
|
||||
scheduleHide();
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = domNode.getBoundingClientRect();
|
||||
clearHideTimer();
|
||||
setHover({
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
height: rect.height,
|
||||
nodeFrom: nodeStart + 1,
|
||||
nodeTo: nodeStart + node.nodeSize - 1,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
editorDom.addEventListener("mousemove", handleMouseMove);
|
||||
editorDom.addEventListener("mouseleave", scheduleHide);
|
||||
|
||||
return () => {
|
||||
editorDom.removeEventListener("mousemove", handleMouseMove);
|
||||
editorDom.removeEventListener("mouseleave", scheduleHide);
|
||||
clearHideTimer();
|
||||
};
|
||||
}, [editor, clearHideTimer, scheduleHide]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!hover) return;
|
||||
editor.chain().focus().setTextSelection({ from: hover.nodeFrom, to: hover.nodeTo }).run();
|
||||
editor.commands.setAiHighlight(hover.nodeFrom, hover.nodeTo);
|
||||
setWritingSelection({ from: hover.nodeFrom, to: hover.nodeTo, text: hover.text });
|
||||
onAiRewrite();
|
||||
}, [hover, editor, setWritingSelection, onAiRewrite]);
|
||||
|
||||
if (!hover) return null;
|
||||
|
||||
const btnTop = hover.top + hover.height / 2 - BTN_SIZE / 2;
|
||||
const btnLeft = Math.max(4, hover.left - BTN_SIZE - BTN_GAP);
|
||||
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: btnTop,
|
||||
left: btnLeft,
|
||||
width: BTN_SIZE,
|
||||
height: BTN_SIZE,
|
||||
zIndex: 9998,
|
||||
}}
|
||||
onMouseEnter={clearHideTimer}
|
||||
onMouseLeave={scheduleHide}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md",
|
||||
"bg-background border border-border shadow-sm",
|
||||
"text-muted-foreground hover:text-amber-500 hover:border-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30",
|
||||
"transition-all duration-150",
|
||||
"animate-in fade-in zoom-in-90 duration-100",
|
||||
)}
|
||||
title="AI 改写此段落"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</button>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
RefreshCw, X, ArrowRightLeft, ArrowDownToLine,
|
||||
Loader2, ChevronDown, Brain, SendHorizontal, Square,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
||||
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
|
||||
|
||||
const ACTION_LABEL: Record<RewriteAction, string> = {
|
||||
polish: "润色",
|
||||
expand: "扩写",
|
||||
shorten: "缩写",
|
||||
tone: "改语气",
|
||||
};
|
||||
|
||||
const TONE_LABEL: Record<RewriteTone, string> = {
|
||||
professional: "更专业",
|
||||
casual: "更口语化",
|
||||
readable: "更易读",
|
||||
academic: "更学术",
|
||||
expressive: "更有文采",
|
||||
};
|
||||
|
||||
interface MenuPos { x: number; y: number }
|
||||
|
||||
interface WritingRewritePreviewProps {
|
||||
pos: MenuPos;
|
||||
status: RewriteStatus;
|
||||
originalText: string;
|
||||
result: string | null;
|
||||
thinking: string | null;
|
||||
error: string | null;
|
||||
action: RewriteAction;
|
||||
tone?: RewriteTone;
|
||||
onReplace: () => void;
|
||||
onInsert: () => void;
|
||||
onRetry: () => void;
|
||||
onAbort: () => void;
|
||||
onClose: () => void;
|
||||
onFollowUp: (instruction: string) => void;
|
||||
}
|
||||
|
||||
export function WritingRewritePreview({
|
||||
pos,
|
||||
status,
|
||||
result,
|
||||
thinking,
|
||||
error,
|
||||
action,
|
||||
tone,
|
||||
onReplace,
|
||||
onInsert,
|
||||
onRetry,
|
||||
onAbort,
|
||||
onClose,
|
||||
onFollowUp,
|
||||
}: WritingRewritePreviewProps) {
|
||||
const isLoading = status === "loading";
|
||||
const isError = status === "error";
|
||||
const isSuccess = status === "success" && result !== null;
|
||||
const hasResult = result !== null && result.length > 0;
|
||||
|
||||
// ── Follow-up input ─────────────────────────────────────────────────────
|
||||
const [followUpInput, setFollowUpInput] = useState("");
|
||||
const followUpInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-focus the follow-up input when generation finishes
|
||||
useEffect(() => {
|
||||
if (status === "success") {
|
||||
setTimeout(() => followUpInputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleFollowUpSubmit = () => {
|
||||
const instruction = followUpInput.trim();
|
||||
if (!instruction || isLoading) return;
|
||||
setFollowUpInput("");
|
||||
onFollowUp(instruction);
|
||||
};
|
||||
|
||||
// ── Thinking expand / collapse ──────────────────────────────────────────
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
const prevStatusRef = useRef<RewriteStatus>(status);
|
||||
|
||||
// Auto-expand when thinking content first streams in during loading
|
||||
useEffect(() => {
|
||||
if (isLoading && thinking) setThinkingOpen(true);
|
||||
}, [isLoading, thinking]);
|
||||
|
||||
// Auto-collapse when generation finishes (loading → success/error)
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current;
|
||||
prevStatusRef.current = status;
|
||||
if (prev === "loading" && status !== "loading") {
|
||||
setThinkingOpen(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// ── Position ────────────────────────────────────────────────────────────
|
||||
const panelWidth = 480;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const h = el.getBoundingClientRect().height;
|
||||
const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - panelWidth - 8);
|
||||
const spaceBelow = window.innerHeight - pos.y - 8;
|
||||
const spaceAbove = pos.y - 8;
|
||||
let safeY: number;
|
||||
if (spaceBelow >= h) {
|
||||
safeY = pos.y + 6;
|
||||
} else if (spaceAbove >= h) {
|
||||
safeY = pos.y - h - 6;
|
||||
} else if (spaceBelow >= spaceAbove) {
|
||||
safeY = Math.max(8, window.innerHeight - h - 8);
|
||||
} else {
|
||||
safeY = 8;
|
||||
}
|
||||
setComputedPos({ left: safeX, top: safeY });
|
||||
}, [pos.x, pos.y]);
|
||||
|
||||
const actionLabel = tone
|
||||
? `${ACTION_LABEL[action]} · ${TONE_LABEL[tone]}`
|
||||
: ACTION_LABEL[action];
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: computedPos?.left ?? pos.x,
|
||||
top: computedPos?.top ?? (pos.y + 6),
|
||||
width: panelWidth,
|
||||
zIndex: 9999,
|
||||
visibility: computedPos ? "visible" : "hidden",
|
||||
}}
|
||||
className="rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/40">
|
||||
<span className="text-xs font-semibold text-foreground">{actionLabel}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="rounded p-1 hover:bg-red-100 dark:hover:bg-red-950/40 text-red-500 hover:text-red-600 transition-colors"
|
||||
title="停止生成"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
disabled={isLoading}
|
||||
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||
title="重试"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-3 space-y-2 max-h-96 overflow-y-auto">
|
||||
|
||||
{/* Thinking — collapsible with animation */}
|
||||
{thinking && (
|
||||
<div className="rounded-md border border-amber-200/60 bg-amber-50/40 dark:border-amber-800/40 dark:bg-amber-950/20 overflow-hidden">
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkingOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-[10px] font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100/40 dark:hover:bg-amber-900/20 transition-colors"
|
||||
>
|
||||
<Brain className="h-3 w-3 shrink-0" />
|
||||
<span className="flex-1 text-left">思考过程</span>
|
||||
{isLoading && (
|
||||
<span className="text-[9px] text-amber-600/70 dark:text-amber-500/60 mr-1">
|
||||
思考中…
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform duration-300",
|
||||
thinkingOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Animated content — grid-rows trick for smooth height transition */}
|
||||
<div
|
||||
style={{ gridTemplateRows: thinkingOpen ? "1fr" : "0fr" }}
|
||||
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-3 pb-2.5 pt-1.5 text-[11px] text-amber-800/80 dark:text-amber-300/70 leading-relaxed whitespace-pre-wrap border-t border-amber-200/40 dark:border-amber-800/30 max-h-44 overflow-y-auto">
|
||||
{thinking}
|
||||
{isLoading && (
|
||||
<span className="inline-block w-0.5 h-3 bg-amber-500 animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result area */}
|
||||
{/* Loading — no text yet */}
|
||||
{isLoading && !hasResult && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
AI 正在生成…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive leading-relaxed">
|
||||
{error ?? "改写失败,请重试"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result text — show while streaming and after success */}
|
||||
{hasResult && (
|
||||
<div className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{result}
|
||||
{isLoading && (
|
||||
<span className="inline-block w-0.5 h-3.5 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions — only after success */}
|
||||
{isSuccess && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t bg-muted/20">
|
||||
<Button size="sm" className="flex-1 h-7 text-xs gap-1.5" onClick={onReplace}>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
替换原文
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs gap-1.5" onClick={onInsert}>
|
||||
<ArrowDownToLine className="h-3 w-3" />
|
||||
插入后面
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Follow-up chat input — shown after success */}
|
||||
{isSuccess && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t bg-background">
|
||||
<input
|
||||
ref={followUpInputRef}
|
||||
type="text"
|
||||
value={followUpInput}
|
||||
onChange={(e) => setFollowUpInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleFollowUpSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="继续优化,如:更简洁一些、语气更正式…"
|
||||
className="flex-1 text-xs bg-muted/50 rounded-md px-2.5 py-1.5 outline-none border border-input focus:border-primary/50 transition-colors placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFollowUpSubmit}
|
||||
disabled={!followUpInput.trim()}
|
||||
className="shrink-0 rounded-md p-1.5 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<SendHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Code,
|
||||
Pilcrow,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ToolbarButton({ onClick, active, disabled, tooltip, children }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // prevent editor losing focus
|
||||
onClick();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-40",
|
||||
active && "bg-accent text-accent-foreground font-semibold",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface WritingToolbarProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
export function WritingToolbar({ editor }: WritingToolbarProps) {
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
|
||||
{/* Block type */}
|
||||
<ToolbarButton
|
||||
tooltip="正文"
|
||||
active={editor.isActive("paragraph")}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
>
|
||||
<Pilcrow className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="一级标题"
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
>
|
||||
<Heading1 className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="二级标题"
|
||||
active={editor.isActive("heading", { level: 2 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2 className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="三级标题"
|
||||
active={editor.isActive("heading", { level: 3 })}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
>
|
||||
<Heading3 className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
tooltip="无序列表"
|
||||
active={editor.isActive("bulletList")}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="有序列表"
|
||||
active={editor.isActive("orderedList")}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
|
||||
{/* Blocks */}
|
||||
<ToolbarButton
|
||||
tooltip="引用"
|
||||
active={editor.isActive("blockquote")}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
>
|
||||
<Quote className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="代码块"
|
||||
active={editor.isActive("codeBlock")}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
>
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
|
||||
{/* Inline marks */}
|
||||
<ToolbarButton
|
||||
tooltip="加粗 (Ctrl+B)"
|
||||
active={editor.isActive("bold")}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="斜体 (Ctrl+I)"
|
||||
active={editor.isActive("italic")}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Italic className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="下划线 (Ctrl+U)"
|
||||
active={editor.isActive("underline")}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<Underline className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
tooltip="删除线"
|
||||
active={editor.isActive("strike")}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
<Strikethrough className="h-3.5 w-3.5" />
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Model selector — pushed to the right */}
|
||||
<div className="ml-auto">
|
||||
<CanvasModelSelector />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,47 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { SendHorizonal, Sparkles, X } from "lucide-react";
|
||||
import { useCallback, useState, type KeyboardEvent } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
RocketIcon,
|
||||
SendHorizonal,
|
||||
Sparkles,
|
||||
Square,
|
||||
X,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorName,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasStreamContext } from "../../contexts/CanvasStreamContext";
|
||||
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
import { useCanvasThreadStream } from "../../hooks/useCanvasThreadStream";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import {
|
||||
buildMessageWithSelection,
|
||||
@@ -18,6 +49,19 @@ import {
|
||||
resolveCodeSelection,
|
||||
} from "../../utils/selection";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CanvasMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
const MODE_LABELS: Record<CanvasMode, string> = {
|
||||
flash: "快速",
|
||||
thinking: "推理",
|
||||
pro: "深入",
|
||||
ultra: "超级",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatWelcome
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -40,20 +84,48 @@ function ChatWelcome() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModeIcon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ModeIcon({
|
||||
mode,
|
||||
className,
|
||||
}: {
|
||||
mode: CanvasMode;
|
||||
className?: string;
|
||||
}) {
|
||||
if (mode === "flash") return <ZapIcon className={className} />;
|
||||
if (mode === "thinking") return <LightbulbIcon className={className} />;
|
||||
if (mode === "pro") return <GraduationCapIcon className={className} />;
|
||||
return <RocketIcon className={cn(className, "text-[#dabb5e]")} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanvasChatPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasChatPanel() {
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const { thread, sendMessage, threadId } = useCanvasThreadStream();
|
||||
const { thread, sendMessage, threadId } = useCanvasStreamContext();
|
||||
const { modelName, setModelName } = useCanvasThreadContext();
|
||||
const { selection, clearSelection } = useCanvasSelection();
|
||||
const { models } = useModels();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [mode, setMode] = useState<CanvasMode>("pro");
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
|
||||
const hasSelection = hasActiveSelection(selection);
|
||||
const isStreaming = thread.isLoading;
|
||||
const hasMessages = thread.messages.length > 0;
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((m) => m.name === modelName) ?? models[0],
|
||||
[models, modelName],
|
||||
);
|
||||
const supportsThinking = selectedModel?.supports_thinking ?? false;
|
||||
|
||||
// Resolve selected code snippet when artifact is a code artifact
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
const resolvedCode =
|
||||
@@ -80,8 +152,12 @@ export function CanvasChatPanel() {
|
||||
})();
|
||||
|
||||
async function handleSend() {
|
||||
if (isStreaming) {
|
||||
await thread.stop();
|
||||
return;
|
||||
}
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
if (!text) return;
|
||||
setInput("");
|
||||
|
||||
const finalText = hasSelection
|
||||
@@ -89,7 +165,7 @@ export function CanvasChatPanel() {
|
||||
: text;
|
||||
|
||||
clearSelection();
|
||||
await sendMessage(finalText);
|
||||
await sendMessage(finalText, { mode });
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -101,9 +177,22 @@ export function CanvasChatPanel() {
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
await sendMessage(answer);
|
||||
await sendMessage(answer, { mode });
|
||||
},
|
||||
[sendMessage],
|
||||
[sendMessage, mode],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(name: string) => {
|
||||
setModelName(name);
|
||||
setModelDialogOpen(false);
|
||||
// If selected model doesn't support thinking, fall back to flash
|
||||
const model = models.find((m) => m.name === name);
|
||||
if (!model?.supports_thinking && mode === "thinking") {
|
||||
setMode("flash");
|
||||
}
|
||||
},
|
||||
[models, mode, setModelName],
|
||||
);
|
||||
|
||||
// MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
|
||||
@@ -128,31 +217,32 @@ export function CanvasChatPanel() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection badge */}
|
||||
{hasSelection && selectionPreview && (
|
||||
<div className="px-3 pt-2 shrink-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{selection.textSelection ? "选中文本:" : "选中代码:"}
|
||||
</span>
|
||||
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
|
||||
aria-label="清除选区"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t p-3 shrink-0">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="p-3 shrink-0">
|
||||
<div className="rounded-2xl border border-border/70 bg-background shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] overflow-hidden transition-shadow focus-within:shadow-[0_2px_16px_-4px_rgba(0,0,0,0.14)] dark:shadow-none dark:focus-within:ring-1 dark:focus-within:ring-border">
|
||||
{/* Selection badge */}
|
||||
{hasSelection && selectionPreview && (
|
||||
<div className="px-3 pt-2.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{selection.textSelection ? "选中文本:" : "选中代码:"}
|
||||
</span>
|
||||
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
|
||||
aria-label="清除选区"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@@ -160,20 +250,157 @@ export function CanvasChatPanel() {
|
||||
placeholder={
|
||||
hasSelection
|
||||
? "针对选中内容发出指令…"
|
||||
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)"
|
||||
: "发消息…"
|
||||
}
|
||||
className="min-h-[40px] max-h-[160px] resize-none text-sm"
|
||||
rows={1}
|
||||
className="border-0 shadow-none rounded-none resize-none text-sm min-h-[60px] max-h-[160px] focus-visible:ring-0 px-3 pt-3 pb-2"
|
||||
rows={2}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={!input.trim() || isStreaming}
|
||||
onClick={() => void handleSend()}
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 pb-2 pt-1 border-t border-border/40">
|
||||
{/* Mode selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
>
|
||||
<ModeIcon mode={mode} className="h-3 w-3" />
|
||||
<span>{MODE_LABELS[mode]}</span>
|
||||
<ChevronDownIcon className="h-3 w-3 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
推理模式
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-2 text-sm",
|
||||
mode === "flash" ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setMode("flash")}
|
||||
>
|
||||
<ZapIcon className="h-3.5 w-3.5" />
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="font-medium">快速</span>
|
||||
<span className="text-xs opacity-70">低延迟,即时响应</span>
|
||||
</div>
|
||||
{mode === "flash" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
{supportsThinking && (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-2 text-sm",
|
||||
mode === "thinking" ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setMode("thinking")}
|
||||
>
|
||||
<LightbulbIcon className="h-3.5 w-3.5" />
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="font-medium">推理</span>
|
||||
<span className="text-xs opacity-70">思维链推导</span>
|
||||
</div>
|
||||
{mode === "thinking" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-2 text-sm",
|
||||
mode === "pro" ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setMode("pro")}
|
||||
>
|
||||
<GraduationCapIcon className="h-3.5 w-3.5" />
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="font-medium">深入</span>
|
||||
<span className="text-xs opacity-70">深度分析与研究</span>
|
||||
</div>
|
||||
{mode === "pro" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"gap-2 text-sm",
|
||||
mode === "ultra" ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setMode("ultra")}
|
||||
>
|
||||
<RocketIcon className={cn("h-3.5 w-3.5", mode === "ultra" && "text-[#dabb5e]")} />
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className={cn("font-medium", mode === "ultra" && "text-[#dabb5e]")}>超级</span>
|
||||
<span className="text-xs opacity-70">全力以赴,最强推理</span>
|
||||
</div>
|
||||
{mode === "ultra" && <CheckIcon className="h-3.5 w-3.5 ml-auto text-[#dabb5e]" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Model selector */}
|
||||
{models.length > 0 && (
|
||||
<ModelSelector open={modelDialogOpen} onOpenChange={setModelDialogOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60 max-w-[120px]"
|
||||
>
|
||||
<ModelSelectorName className="truncate text-xs font-normal">
|
||||
{selectedModel?.display_name ?? "选择模型"}
|
||||
</ModelSelectorName>
|
||||
<ChevronDownIcon className="h-3 w-3 shrink-0 opacity-60" />
|
||||
</Button>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent title="选择模型">
|
||||
<ModelSelectorInput placeholder="搜索模型…" />
|
||||
<ModelSelectorList>
|
||||
{models.map((m) => (
|
||||
<ModelSelectorItem
|
||||
key={m.name}
|
||||
value={m.name}
|
||||
onSelect={() => handleModelSelect(m.name)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{m.model}
|
||||
</span>
|
||||
</div>
|
||||
{m.name === modelName ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Send / Stop button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant={isStreaming ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"h-7 w-7 shrink-0 rounded-full",
|
||||
isStreaming && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!isStreaming && !input.trim()}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
) : (
|
||||
<SendHorizonal className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<CanvasStreamProvider>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
@@ -54,5 +56,6 @@ export function CanvasResizableLayout() {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</CanvasStreamProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<CanvasHeader />
|
||||
<CanvasResizableLayout />
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<CanvasHeader />
|
||||
<CanvasResizableLayout />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useCanvasThreadStream } from "../hooks/useCanvasThreadStream";
|
||||
|
||||
type CanvasStreamContextValue = ReturnType<typeof useCanvasThreadStream>;
|
||||
|
||||
const CanvasStreamContext = createContext<CanvasStreamContextValue | null>(null);
|
||||
|
||||
export function CanvasStreamProvider({ children }: { children: ReactNode }) {
|
||||
const value = useCanvasThreadStream();
|
||||
return (
|
||||
<CanvasStreamContext.Provider value={value}>
|
||||
{children}
|
||||
</CanvasStreamContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCanvasStreamContext() {
|
||||
const ctx = useContext(CanvasStreamContext);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useCanvasStreamContext must be used within CanvasStreamProvider",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -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<CanvasSelectionState>(EMPTY_SELECTION);
|
||||
const [writingSelection, setWritingSelection] =
|
||||
useState<WritingSelection | null>(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}
|
||||
|
||||
@@ -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<HighlightRange | null>("aiHighlight");
|
||||
|
||||
// Augment Tiptap's Commands interface so TS knows about our custom commands
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
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<HighlightRange | null>({
|
||||
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" }),
|
||||
]);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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 <EditorContent editor={editor} className={className} />;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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],
|
||||
|
||||
@@ -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<CanvasThreadValues>({
|
||||
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("<artifact")
|
||||
? (() => {
|
||||
const body =
|
||||
currentContent.type === "code"
|
||||
? currentContent.code
|
||||
: currentContent.fullMarkdown;
|
||||
const tag =
|
||||
currentContent.type === "code"
|
||||
? `language="${currentContent.language}"`
|
||||
: "";
|
||||
return `${trimmed}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
|
||||
})()
|
||||
: trimmed;
|
||||
|
||||
try {
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{ type: "human", content: [{ type: "text", text: trimmed }] },
|
||||
{ type: "human", content: [{ type: "text", text: messageText }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<string>(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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
// ── <think>…</think> 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("<think>");
|
||||
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("</think>");
|
||||
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<RewriteStatus>("idle");
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [thinking, setThinking] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(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 };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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}
|
||||
|
||||
<artifact ${tag}>
|
||||
${artifactContent}
|
||||
</artifact>`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user