Merge remote-tracking branch 'origin/master' into ji
# Conflicts: # offline-backend-20260512/backend/app/gateway/deps.py
This commit is contained in:
commit
449703a6f0
289 files changed
+80131
-4314
No files matched your search
@@ -94,3 +94,7 @@ export function useArtifacts() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArtifacts } from "../artifacts";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
@@ -201,8 +201,7 @@ function ToolCall({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||
useArtifacts();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
if (name === "web_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -329,43 +328,43 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
const artifactPath =
|
||||
name === "write_file" && path
|
||||
? new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString()
|
||||
: path;
|
||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
if (!artifactPath || selectedArtifact === artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath, true);
|
||||
setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
const artifactPath =
|
||||
name === "write_file" && path
|
||||
? new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString()
|
||||
: path;
|
||||
if (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath, true);
|
||||
artifacts.setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
@@ -412,6 +411,17 @@ function ToolCall({
|
||||
icon={ListTodoIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "update_artifact") {
|
||||
const title = typeof args.title === "string" ? args.title : undefined;
|
||||
const type = args.type as string | undefined;
|
||||
const label = title ?? (type === "code" ? "生成代码" : "生成文档");
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={NotebookPenIcon}>
|
||||
<ChainOfThoughtSearchResult>
|
||||
{type === "code" ? "代码文件" : "Markdown 文档"}
|
||||
</ChainOfThoughtSearchResult>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
parseUploadedFiles,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
@@ -202,7 +203,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;
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
import { env } from "@/env";
|
||||
|
||||
/**
|
||||
* 内网知识库「查看原文」详情页地址前缀。
|
||||
* 素材的 recUuid 直接拼接在其后即为完整原文链接。
|
||||
*/
|
||||
export const INTRANET_SOURCE_URL_BASE =
|
||||
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid=";
|
||||
|
||||
/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */
|
||||
export function buildIntranetSourceUrl(recUuid?: string | null): string {
|
||||
if (!recUuid) return "";
|
||||
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
|
||||
}
|
||||
|
||||
function getBaseOrigin() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
@@ -18,6 +31,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,69 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
export interface ArticleType {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
prompt_hint: string | null;
|
||||
default_word_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateArticleTypeRequest {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
prompt_hint?: string;
|
||||
default_word_count?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateArticleTypeRequest {
|
||||
key?: string;
|
||||
label?: string;
|
||||
description?: string | null;
|
||||
prompt_hint?: string | null;
|
||||
default_word_count?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/article-types`;
|
||||
|
||||
export async function listArticleTypes(): Promise<ArticleType[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function createArticleType(data: CreateArticleTypeRequest): Promise<ArticleType> {
|
||||
const resp = await apiFetch(BASE(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.status === 409) throw new Error(`文章类型标识 "${data.key}" 已存在`);
|
||||
if (!resp.ok) throw new Error(`创建文章类型失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function updateArticleType(id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.status === 409) throw new Error(`文章类型标识已存在`);
|
||||
if (resp.status === 404) throw new Error(`文章类型不存在`);
|
||||
if (!resp.ok) throw new Error(`更新文章类型失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteArticleType(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (resp.status === 404) throw new Error(`文章类型不存在`);
|
||||
if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type { ProgressEvent, CompletedIntervention } from '../types/ai-writing';
|
||||
|
||||
export interface AIWritingSessionSummary {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
title: string;
|
||||
user_intent: string | null;
|
||||
status: string;
|
||||
draft_title: string | null;
|
||||
draft_markdown: string | null;
|
||||
completed_interventions: CompletedInterventionRecord[] | null;
|
||||
review_result: Record<string, unknown> | null;
|
||||
transcript: AIWritingTranscript | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CompletedInterventionRecord {
|
||||
pausePoint: string;
|
||||
action: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** 完整对话时间线快照:与实时对话用同一份数据结构,历史回显时直接喂给 AIWritingTimeline。 */
|
||||
export interface AIWritingTranscript {
|
||||
progressEvents: ProgressEvent[];
|
||||
completedInterventions: CompletedIntervention[];
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
|
||||
|
||||
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function getAIWritingSession(id: string): Promise<AIWritingSessionSummary> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`);
|
||||
if (!resp.ok) throw new Error(`获取会话失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function renameAIWritingSession(id: string, title: string): Promise<AIWritingSessionSummary> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`重命名失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteAIWritingSession(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
|
||||
}
|
||||
|
||||
/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */
|
||||
export async function saveAIWritingTranscript(
|
||||
id: string,
|
||||
transcript: AIWritingTranscript,
|
||||
): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transcript }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
ProgressEvent,
|
||||
ProgressEventType,
|
||||
UserInterventionPayload,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
|
||||
|
||||
/** 启动写作流程,返回 session_id */
|
||||
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
|
||||
const resp = await apiFetch(`${BASE()}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_intent: req.userIntent,
|
||||
article_type: req.articleType,
|
||||
target_audience: req.targetAudience,
|
||||
word_count_target: req.wordCountTarget,
|
||||
model_name: req.modelName ?? '',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.session_id;
|
||||
}
|
||||
|
||||
const SSE_EVENT_TYPES: ProgressEventType[] = [
|
||||
'connected', 'step_start', 'step_done', 'research_progress',
|
||||
'materials_ready', 'outline_chunk', 'outline_ready',
|
||||
'draft_chunk', 'draft_ready',
|
||||
'review_chunk', 'review_item_done', 'review_ready',
|
||||
'await_user', 'resumed', 'notice', 'done', 'error',
|
||||
];
|
||||
|
||||
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
|
||||
|
||||
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...m,
|
||||
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
|
||||
publishedAt: m.published_at ?? m.publishedAt ?? '',
|
||||
sourceType: m.source_type ?? m.sourceType ?? 'internet',
|
||||
recUuid: m.recUuid ?? m.rec_uuid ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...s,
|
||||
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
|
||||
keyPoints: s.key_points ?? s.keyPoints ?? [],
|
||||
materialIds: s.material_ids ?? s.materialIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutline(o: unknown): unknown {
|
||||
if (!o || typeof o !== 'object') return o;
|
||||
const obj = o as Record<string, unknown>;
|
||||
const sections = Array.isArray(obj.sections)
|
||||
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
|
||||
: [];
|
||||
return { ...obj, sections };
|
||||
}
|
||||
|
||||
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
|
||||
return { ...i };
|
||||
}
|
||||
|
||||
function normalizeReviewResult(r: unknown): unknown {
|
||||
if (!r || typeof r !== 'object') return r;
|
||||
const obj = r as Record<string, unknown>;
|
||||
return {
|
||||
...obj,
|
||||
factScore: obj.fact_score ?? obj.factScore ?? 0,
|
||||
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
|
||||
languageScore: obj.language_score ?? obj.languageScore ?? 0,
|
||||
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
|
||||
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
|
||||
revisionNotes: Array.isArray(obj.revision_notes)
|
||||
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
|
||||
: Array.isArray(obj.revisionNotes)
|
||||
? obj.revisionNotes
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
|
||||
const out: Record<string, unknown> = { ...parsed };
|
||||
|
||||
// 通用字段
|
||||
if ('agent_name' in out) { out.agentName = out.agent_name; }
|
||||
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
|
||||
|
||||
// materials 数组(materials_ready 和 await_user 里都可能有)
|
||||
if (Array.isArray(out.materials)) {
|
||||
out.materials = out.materials.map((m: unknown) =>
|
||||
normalizeMaterial(m as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
// outline
|
||||
if (out.outline) out.outline = normalizeOutline(out.outline);
|
||||
|
||||
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
|
||||
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
|
||||
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
|
||||
if ('revision_count' in out) out.revisionCount = out.revision_count;
|
||||
|
||||
// review_ready
|
||||
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
||||
|
||||
// review_item_done (streaming per-category result from sse_queue)
|
||||
if ('category' in out) out.reviewItemCategory = out.category;
|
||||
if ('score' in out) out.reviewItemScore = out.score;
|
||||
if ('issues' in out && Array.isArray(out.issues)) {
|
||||
out.reviewItemIssues = out.issues.map((x: unknown) =>
|
||||
normalizeReviewIssue(x as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
return out as Partial<ProgressEvent>;
|
||||
}
|
||||
|
||||
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
|
||||
export function subscribeToProgress(
|
||||
sessionId: string,
|
||||
onEvent: (event: ProgressEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
|
||||
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentType = '';
|
||||
let currentData = '';
|
||||
|
||||
while (!stopped) {
|
||||
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) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
currentData = line.slice(6).trim();
|
||||
} else if (line === '' && currentType && currentData) {
|
||||
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentData);
|
||||
const normalized = normalizeEvent(parsed);
|
||||
onEvent({
|
||||
type: currentType as ProgressEventType,
|
||||
sessionId,
|
||||
message: '',
|
||||
...normalized,
|
||||
} as ProgressEvent);
|
||||
} catch {
|
||||
// malformed JSON — skip
|
||||
}
|
||||
}
|
||||
currentType = '';
|
||||
currentData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { stopped = true; };
|
||||
}
|
||||
|
||||
/** 提交用户干预决策,恢复暂停的流程 */
|
||||
export async function resumeWriting(
|
||||
sessionId: string,
|
||||
payload: UserInterventionPayload,
|
||||
): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: payload.action,
|
||||
approved_material_ids: payload.approvedMaterialIds ?? [],
|
||||
extra_keywords: payload.extraKeywords ?? [],
|
||||
edited_outline: payload.editedOutline ?? null,
|
||||
outline_feedback: payload.outlineFeedback ?? '',
|
||||
user_revision_notes: payload.userRevisionNotes ?? '',
|
||||
override_verdict: payload.overrideVerdict ?? false,
|
||||
selected_issue_indices: payload.selectedIssueIndices ?? null,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
|
||||
export type RewriteTone =
|
||||
| "professional"
|
||||
| "casual"
|
||||
| "readable"
|
||||
| "academic"
|
||||
| "expressive";
|
||||
|
||||
export interface RewriteRequest {
|
||||
threadId?: string | null;
|
||||
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(`${getBackendBaseURL()}/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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { FileText, PenLine } from 'lucide-react';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
|
||||
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
|
||||
import type { CanvasArtifactText } from '../../types/artifact';
|
||||
|
||||
function DraftWaitingPlaceholder() {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-5 select-none">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center">
|
||||
<FileText className="w-8 h-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<PenLine className="w-3.5 h-3.5 text-primary/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1.5 max-w-[200px]">
|
||||
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
完成写作流程后,AI 生成的内容将实时出现在这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIWritingDraftPanel() {
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10 border-l">
|
||||
<DraftWaitingPlaceholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden border-l">
|
||||
{/* 标题栏 */}
|
||||
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{content?.title ?? '草稿预览'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{content && !isCodeArtifact(content) ? (
|
||||
<TextArtifactRenderer content={content as CanvasArtifactText} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PenLine } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'focus-visible:ring-ring disabled:opacity-50';
|
||||
|
||||
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
// 初始化时加载文章类型
|
||||
useEffect(() => {
|
||||
refreshTypes();
|
||||
}, [refreshTypes]);
|
||||
|
||||
// 类型加载完成后:若当前选中值不在列表中则切换为第一项
|
||||
useEffect(() => {
|
||||
if (articleTypes.length === 0) return;
|
||||
const keys = articleTypes.map((t) => t.key);
|
||||
if (!keys.includes(articleType)) {
|
||||
const first = articleTypes[0];
|
||||
setArticleType(first.key);
|
||||
setWordCountInput(String(first.default_word_count));
|
||||
}
|
||||
}, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 切换文章类型时自动填入该类型的默认字数
|
||||
function handleArticleTypeChange(key: string) {
|
||||
setArticleType(key);
|
||||
const found = articleTypes.find((t) => t.key === key);
|
||||
if (found) {
|
||||
setWordCountInput(String(found.default_word_count));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`${getBackendBaseURL()}/api/models`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const list: ModelOption[] = (data.models ?? []).map(
|
||||
(m: { name: string; display_name?: string }) => ({
|
||||
name: m.name,
|
||||
displayName: m.display_name || m.name,
|
||||
}),
|
||||
);
|
||||
setModels(list);
|
||||
if (list.length > 0 && !modelName) {
|
||||
setModelName(list[0].name);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function handleWordCountBlur() {
|
||||
const num = parseInt(wordCountInput, 10);
|
||||
if (isNaN(num) || num < 100) {
|
||||
setWordCountInput('800');
|
||||
} else {
|
||||
setWordCountInput(String(num));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||
onSubmit({
|
||||
userIntent: intent.trim(),
|
||||
articleType,
|
||||
targetAudience: audience.trim() || '普通读者',
|
||||
wordCountTarget: wordCount,
|
||||
modelName: modelName || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
|
||||
<Textarea
|
||||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
|
||||
<Select
|
||||
value={articleType}
|
||||
onValueChange={handleArticleTypeChange}
|
||||
disabled={disabled || typesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleTypes.map((t) => (
|
||||
<SelectItem key={t.key} value={t.key} className="text-xs">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={100}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="800"
|
||||
value={wordCountInput}
|
||||
onChange={(e) => setWordCountInput(e.target.value)}
|
||||
onBlur={handleWordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
|
||||
<Select
|
||||
value={modelName}
|
||||
onValueChange={setModelName}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="选择模型…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
|
||||
<PenLine className="size-3.5" />
|
||||
开始 AI 写作
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
in_progress: { label: '进行中', color: 'text-blue-500' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||
done: { label: '完成', color: 'text-green-500' },
|
||||
error: { label: '错误', color: 'text-destructive' },
|
||||
};
|
||||
|
||||
function formatRelative(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs} 小时前`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
session: AIWritingSessionSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onRename: (title: string) => void;
|
||||
}
|
||||
|
||||
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? { label: session.status, color: 'text-muted-foreground' };
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus();
|
||||
}, [editing]);
|
||||
|
||||
function commitRename() {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== session.title) onRename(trimmed);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
else if (e.key === 'Escape') { setEditValue(session.title); setEditing(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
|
||||
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
|
||||
)}
|
||||
onClick={!editing ? onSelect : undefined}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 text-xs bg-background border rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button className="shrink-0 text-green-500 hover:text-green-600" onClick={commitRename}>
|
||||
<Check className="w-3 h-3" />
|
||||
</button>
|
||||
<button className="shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setEditValue(session.title); setEditing(false); }}>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="text-xs font-medium truncate leading-snug"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true); }}
|
||||
title="双击重命名"
|
||||
>
|
||||
{session.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮,hover 时显示 */}
|
||||
{!editing && (
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 shrink-0 p-0.5 text-muted-foreground hover:text-destructive transition-opacity"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AIWritingHistoryDropdownProps {
|
||||
sessions: AIWritingSessionSummary[];
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
onSelect: (session: AIWritingSessionSummary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function AIWritingHistoryDropdown({
|
||||
sessions,
|
||||
selectedId,
|
||||
loading,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onRefresh,
|
||||
}: AIWritingHistoryDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
setOpen((v) => {
|
||||
if (!v) onRefresh();
|
||||
return !v;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
onClick={handleOpen}
|
||||
title="查看历史记录"
|
||||
>
|
||||
<History className="size-3" />
|
||||
历史
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border bg-popover shadow-lg overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-xs font-semibold">写作历史</span>
|
||||
<button
|
||||
className={cn('text-muted-foreground hover:text-foreground transition-transform', loading && 'animate-spin')}
|
||||
onClick={onRefresh}
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground">
|
||||
{loading ? '加载中…' : '暂无历史记录'}
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SessionItem
|
||||
key={s.id}
|
||||
session={s}
|
||||
isSelected={s.id === selectedId}
|
||||
onSelect={() => { onSelect(s); setOpen(false); }}
|
||||
onDelete={() => onDelete(s.id)}
|
||||
onRename={(title) => onRename(s.id, title)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
|
||||
|
||||
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
|
||||
const PAUSE_LABELS: Record<string, string> = {
|
||||
material_confirm: '素材确认',
|
||||
outline_confirm: '大纲确认',
|
||||
draft_confirm: '草稿确认',
|
||||
review_confirm: '编辑意见处理',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
approve: '已批准',
|
||||
re_search: '重新搜索',
|
||||
re_outline: '重新规划',
|
||||
finalize: '直接定稿',
|
||||
to_editor: '提交审核',
|
||||
user_revise: '手动修改',
|
||||
accept_review: '接受意见',
|
||||
force_finalize: '强制定稿',
|
||||
};
|
||||
|
||||
const PAUSE_ICONS: Record<string, React.ElementType> = {
|
||||
material_confirm: Search,
|
||||
outline_confirm: PenLine,
|
||||
draft_confirm: FileText,
|
||||
review_confirm: ClipboardEdit,
|
||||
};
|
||||
|
||||
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) {
|
||||
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2;
|
||||
const ts = record.timestamp
|
||||
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />}
|
||||
</div>
|
||||
<div className="pb-4 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{ACTION_LABELS[record.action] ?? record.action}
|
||||
</span>
|
||||
</div>
|
||||
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AIWritingHistoryViewProps {
|
||||
session: AIWritingSessionSummary;
|
||||
}
|
||||
|
||||
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
|
||||
const interventions = session.completed_interventions ?? [];
|
||||
const transcript = session.transcript;
|
||||
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
|
||||
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
|
||||
const createdAt = session.created_at
|
||||
? new Date(session.created_at).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 元信息 */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
|
||||
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
|
||||
{session.user_intent && (
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{session.user_intent}
|
||||
</p>
|
||||
)}
|
||||
{createdAt && (
|
||||
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasTranscript ? (
|
||||
/* 完整对话时间线回显 */
|
||||
<AIWritingTimeline
|
||||
events={transcript!.progressEvents}
|
||||
completedInterventions={transcript!.completedInterventions ?? []}
|
||||
currentPause={null}
|
||||
/>
|
||||
) : (
|
||||
/* 旧会话无时间线快照时,回退到精简阶段列表 */
|
||||
<div className="px-4 py-3 space-y-4">
|
||||
{interventions.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
|
||||
<div>
|
||||
{interventions.map((rec, i) => (
|
||||
<InterventionTimelineItem
|
||||
key={i}
|
||||
record={rec}
|
||||
isLast={i === interventions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
|
||||
)}
|
||||
|
||||
{session.status === 'done' && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
<span>写作完成</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, PenLine } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { getCurrentArtifactContent } from '../../utils/artifact';
|
||||
|
||||
export function AIWritingPageHeader() {
|
||||
const navigate = useNavigate();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => navigate('/page/canvas')}
|
||||
title="返回 Canvas 列表"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<PenLine className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm font-semibold">AI 智能写作</span>
|
||||
|
||||
{currentContent?.title && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{currentContent.title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useAIWriting } from '../../hooks/useAIWriting';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { AIWritingForm } from './AIWritingForm';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||
import { DraftStreamingView } from './DraftStreamingView';
|
||||
import { OutlineStreamingView } from './OutlineStreamingView';
|
||||
import { ReviewStreamingView } from './ReviewStreamingView';
|
||||
import { RevisionSummaryCard } from './RevisionSummaryCard';
|
||||
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
|
||||
import { AIWritingHistoryView } from './AIWritingHistoryView';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
researching: '素材收集专家搜集素材中…',
|
||||
awaiting_material_confirm: '等待确认素材',
|
||||
writing_outline: '作家规划大纲中…',
|
||||
awaiting_outline_confirm: '等待确认大纲',
|
||||
writing_draft: '作家写作中…',
|
||||
awaiting_draft_confirm: '等待确认草稿',
|
||||
reviewing: '编辑审核中…',
|
||||
awaiting_review_confirm: '等待处理编辑意见',
|
||||
revising: '按意见修改中…',
|
||||
done: '写作完成 ✓',
|
||||
error: '出错了',
|
||||
};
|
||||
|
||||
export function AIWritingPanel() {
|
||||
const navigate = useNavigate();
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||
|
||||
// History view state — null means normal writing mode
|
||||
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
|
||||
|
||||
const isIdle = state.status === 'idle';
|
||||
const isDone = state.status === 'done';
|
||||
const isError = state.status === 'error';
|
||||
const isRunning = !isIdle && !isDone && !isError;
|
||||
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
|
||||
|
||||
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
|
||||
const isAutoScrollingRef = useRef(false);
|
||||
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
|
||||
const autoScrollRef = useRef(true);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const disableAutoScroll = useCallback(() => {
|
||||
autoScrollRef.current = false;
|
||||
setAutoScroll(false);
|
||||
}, []);
|
||||
|
||||
// 新会话 / 重置后恢复自动滚底
|
||||
useEffect(() => {
|
||||
if (isIdle) {
|
||||
autoScrollRef.current = true;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [isIdle]);
|
||||
|
||||
// 开始写作 / 点击继续后恢复自动滚底
|
||||
useEffect(() => {
|
||||
if (isRunning && !state.isPaused) {
|
||||
autoScrollRef.current = true;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [isRunning, state.isPaused]);
|
||||
|
||||
// 监听用户手动滚动(挂载一次)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
|
||||
};
|
||||
|
||||
// scroll 事件:兼容拖动滚动条的场景
|
||||
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
|
||||
let lastTop = el.scrollTop;
|
||||
const onScroll = () => {
|
||||
const cur = el.scrollTop;
|
||||
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
|
||||
disableAutoScroll();
|
||||
}
|
||||
lastTop = cur;
|
||||
};
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: true });
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [disableAutoScroll]);
|
||||
|
||||
// 内容变化时若自动滚底开启则滚到底部
|
||||
useEffect(() => {
|
||||
// 检查同步 ref,避免 state 异步导致的时序竞争
|
||||
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
isAutoScrollingRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
|
||||
}, [
|
||||
autoScroll, // 点击按钮后重新触发一次
|
||||
isRunning,
|
||||
state.progressEvents.length,
|
||||
state.streamingDraftSections.length,
|
||||
state.streamingReviewItems.length,
|
||||
state.outlineStreamingText,
|
||||
state.currentPause,
|
||||
]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
isAutoScrollingRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
|
||||
autoScrollRef.current = true;
|
||||
setAutoScroll(true);
|
||||
}, []);
|
||||
|
||||
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
|
||||
setHistorySession(session);
|
||||
// Push historical draft to the right panel
|
||||
if (session.draft_markdown) {
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [{
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: session.draft_title ?? session.title,
|
||||
fullMarkdown: session.draft_markdown,
|
||||
}],
|
||||
});
|
||||
} else {
|
||||
setArtifact(null);
|
||||
}
|
||||
}, [setArtifact]);
|
||||
|
||||
const handleNewSession = useCallback(() => {
|
||||
setHistorySession(null);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const showNewButton = historySession !== null || state.status !== 'idle';
|
||||
|
||||
// History view mode
|
||||
if (historySession) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
|
||||
{historySession.draft_title ?? historySession.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => navigate('/page/canvas/article-types')}
|
||||
title="文章类型配置"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||
<Plus className="size-3" />
|
||||
新建对话
|
||||
</Button>
|
||||
<AIWritingHistoryDropdown
|
||||
sessions={sessions}
|
||||
selectedId={historySession.id}
|
||||
loading={sessionsLoading}
|
||||
onSelect={handleSelectHistory}
|
||||
onDelete={remove}
|
||||
onRename={rename}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AIWritingHistoryView session={historySession} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => navigate('/page/canvas/article-types')}
|
||||
title="文章类型配置"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</Button>
|
||||
{showNewButton && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||
<Plus className="size-3" />
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && !state.isPaused && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={pauseWriting}
|
||||
>
|
||||
<Square className="size-3 fill-current" />
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
{state.isPaused && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
|
||||
onClick={resumeWriting}
|
||||
>
|
||||
<Play className="size-3 fill-current" />
|
||||
继续
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||||
<RotateCcw className="size-3" />
|
||||
重置
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(isDone || isError) && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||||
<RotateCcw className="size-3" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
<AIWritingHistoryDropdown
|
||||
sessions={sessions}
|
||||
selectedId={null}
|
||||
loading={sessionsLoading}
|
||||
onSelect={handleSelectHistory}
|
||||
onDelete={remove}
|
||||
onRename={rename}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||||
{/* 未开始时显示表单 */}
|
||||
{isIdle && (
|
||||
<AIWritingForm onSubmit={startWriting} />
|
||||
)}
|
||||
|
||||
{/* 运行中或完成后显示时间线 */}
|
||||
{!isIdle && (
|
||||
<AIWritingTimeline
|
||||
events={state.progressEvents}
|
||||
completedInterventions={state.completedInterventions}
|
||||
currentPause={state.currentPause}
|
||||
currentInterventionSlot={
|
||||
state.currentPause ? (
|
||||
<InterventionCard
|
||||
pauseEvent={state.currentPause}
|
||||
materials={state.materials}
|
||||
outline={state.outline}
|
||||
reviewResult={state.reviewResult}
|
||||
draftTitle={state.currentDraftTitle}
|
||||
revisionCount={state.revisionCount}
|
||||
onSubmit={submitIntervention}
|
||||
disabled={false}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 大纲流式思考框 */}
|
||||
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
|
||||
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
|
||||
)}
|
||||
|
||||
{/* 编辑审核流式结果 */}
|
||||
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
|
||||
<ReviewStreamingView items={state.streamingReviewItems} />
|
||||
)}
|
||||
|
||||
{/* 修改完成摘要 */}
|
||||
{state.revisionSummary && (
|
||||
<div className="px-4 pb-2">
|
||||
<RevisionSummaryCard summary={state.revisionSummary} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 草稿流式打字机视图 */}
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
|
||||
<DraftStreamingView
|
||||
title={state.streamingDraftTitle}
|
||||
sections={state.streamingDraftSections}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSkeleton &&
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
|
||||
<div className="px-4 pb-4">
|
||||
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<WritingSkeletonCard status="writing_draft" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && state.error && (
|
||||
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>{/* 滚动内容区结束 */}
|
||||
|
||||
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
|
||||
{isRunning && !autoScroll && (
|
||||
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
|
||||
>
|
||||
<ChevronsDown className="size-3.5" />
|
||||
回到底部
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* 相对定位容器结束 */}
|
||||
|
||||
{/* 状态栏 */}
|
||||
{!isIdle && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
|
||||
isError ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{state.isPaused ? (
|
||||
<>
|
||||
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
|
||||
已暂停
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isRunning && (
|
||||
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
{state.status === 'researching' && state.researchProgress
|
||||
? state.researchProgress
|
||||
: STATUS_LABEL[state.status] ?? state.status}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
|
||||
import { TimelineStep } from './TimelineStep';
|
||||
import { CompletedInterventionCard } from './CompletedInterventionCard';
|
||||
|
||||
interface AIWritingTimelineProps {
|
||||
events: ProgressEvent[];
|
||||
completedInterventions: CompletedIntervention[];
|
||||
currentPause: ProgressEvent | null;
|
||||
currentInterventionSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DISPLAYED_TYPES = new Set([
|
||||
'step_start',
|
||||
'step_done',
|
||||
'materials_ready',
|
||||
'outline_ready',
|
||||
'draft_ready',
|
||||
'review_ready',
|
||||
'await_user',
|
||||
'resumed',
|
||||
'notice',
|
||||
'done',
|
||||
'error',
|
||||
]);
|
||||
|
||||
export function AIWritingTimeline({
|
||||
events,
|
||||
completedInterventions,
|
||||
currentPause,
|
||||
currentInterventionSlot,
|
||||
}: AIWritingTimelineProps) {
|
||||
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
// 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录
|
||||
let awaitUserIdx = 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-0">
|
||||
{visible.map((event, idx) => {
|
||||
const isLast = idx === visible.length - 1;
|
||||
let slot: React.ReactNode = null;
|
||||
|
||||
if (event.type === 'await_user') {
|
||||
const completedRecord = completedInterventions[awaitUserIdx];
|
||||
awaitUserIdx += 1;
|
||||
|
||||
if (completedRecord) {
|
||||
// 已完成的干预点 → 渲染折叠历史卡
|
||||
slot = <CompletedInterventionCard record={completedRecord} />;
|
||||
} else if (currentPause && isLast) {
|
||||
// 当前活跃干预点 → 渲染操作卡
|
||||
slot = currentInterventionSlot;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
|
||||
{slot}
|
||||
</TimelineStep>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle2, ChevronRight, ChevronDown,
|
||||
ExternalLink, ListTree, FileText,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildIntranetSourceUrl } from '@/core/config';
|
||||
import type { CompletedIntervention } from '../../types/ai-writing';
|
||||
import { OutlineCard } from './OutlineCard';
|
||||
import { ReviewCard } from './ReviewCard';
|
||||
import { ScoreBar } from './ScoreBar';
|
||||
|
||||
interface Props {
|
||||
record: CompletedIntervention;
|
||||
}
|
||||
|
||||
export function CompletedInterventionCard({ record }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (record.pausePoint === 'material_confirm') {
|
||||
return <MaterialCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
if (record.pausePoint === 'outline_confirm') {
|
||||
return <OutlineCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
if (record.pausePoint === 'draft_confirm') {
|
||||
return <DraftCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
if (record.pausePoint === 'review_confirm') {
|
||||
return <ReviewCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 素材确认历史卡 ────────────────────────────────────────────────────────────
|
||||
|
||||
function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const count = record.materials?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<FileText className="size-3.5 text-blue-500 shrink-0" />
|
||||
<span className="flex-1 font-medium">
|
||||
{count === 0 && record.action !== 're_search' ? (
|
||||
'已在无素材的情况下继续写作'
|
||||
) : (
|
||||
<>
|
||||
已确认{' '}
|
||||
<span className="text-muted-foreground font-normal">{count} 条素材</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.materials && record.materials.length > 0 && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
|
||||
{record.materials.map((m) => {
|
||||
const pct = Math.round(m.relevanceScore * 100);
|
||||
const barColor =
|
||||
m.relevanceScore >= 0.8 ? 'bg-green-500'
|
||||
: m.relevanceScore >= 0.5 ? 'bg-orange-400'
|
||||
: 'bg-red-400';
|
||||
return (
|
||||
<div key={m.id} className="rounded border border-border/50 bg-background px-2.5 py-1.5 space-y-1">
|
||||
<p className="font-medium leading-snug line-clamp-2">{m.title}</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="truncate max-w-[100px]">{m.source}</span>
|
||||
{m.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{m.publishedAt}</span></>}
|
||||
<div className="flex flex-1 items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
|
||||
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
|
||||
</div>
|
||||
{m.recUuid && (
|
||||
<button type="button"
|
||||
onClick={() => window.open(buildIntranetSourceUrl(m.recUuid), '_blank', 'noopener,noreferrer')}
|
||||
className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5">
|
||||
原文 <ExternalLink className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 大纲确认历史卡 ────────────────────────────────────────────────────────────
|
||||
|
||||
function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const sectionCount = record.outline?.sections.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
||||
<span className="flex-1 font-medium">
|
||||
已确认大纲
|
||||
{record.outline?.title && (
|
||||
<span className="ml-1 text-muted-foreground font-normal">
|
||||
《{record.outline.title}》
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
|
||||
</span>
|
||||
{record.outline && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.outline && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<OutlineCard outline={record.outline} readOnly />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 草稿确认历史卡 ────────────────────────────────────────────────────────────
|
||||
|
||||
function DraftCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿';
|
||||
const wordCount = record.draftWordCount ?? 0;
|
||||
const hasReview = !!record.reviewResult;
|
||||
|
||||
const actionLabel =
|
||||
record.action === 'finalize' ? '已确认定稿' :
|
||||
record.action === 'user_revise' ? '已要求修改' :
|
||||
'已提交编辑审核';
|
||||
|
||||
const actionColor =
|
||||
record.action === 'finalize' ? 'text-green-600 dark:text-green-400' :
|
||||
record.action === 'user_revise' ? 'text-orange-500 dark:text-orange-400' :
|
||||
'text-blue-500 dark:text-blue-400';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasReview ? onToggle : undefined}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-left',
|
||||
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<span className={cn('font-medium shrink-0', actionColor)}>{actionLabel}</span>
|
||||
<span className="text-muted-foreground flex-1 truncate">
|
||||
{title}
|
||||
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
|
||||
</span>
|
||||
{hasReview && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.reviewResult && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<ReviewCard review={record.reviewResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 编辑审核确认历史卡 ────────────────────────────────────────────────────────
|
||||
|
||||
function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const isForce = record.action === 'force_finalize';
|
||||
const hasReview = !!record.reviewResult;
|
||||
|
||||
const actionLabel = isForce ? '已强制定稿(忽略编辑意见)' : '已采纳编辑意见,重新写作';
|
||||
const actionColor = isForce ? 'text-orange-500 dark:text-orange-400' : 'text-blue-500 dark:text-blue-400';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasReview ? onToggle : undefined}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-left',
|
||||
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<span className={cn('font-medium flex-1', actionColor)}>{actionLabel}</span>
|
||||
{hasReview && record.reviewResult && (
|
||||
<span className="text-muted-foreground tabular-nums shrink-0">
|
||||
{record.reviewResult.overallScore} / 100
|
||||
</span>
|
||||
)}
|
||||
{hasReview && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.reviewResult && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
|
||||
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
|
||||
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StreamingDraftSection } from '../../types/ai-writing';
|
||||
|
||||
interface DraftStreamingViewProps {
|
||||
title: string;
|
||||
sections: StreamingDraftSection[];
|
||||
}
|
||||
|
||||
function TypewriterCursor() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block w-0.5 h-4 bg-foreground align-middle ml-0.5 animate-[blink_1s_step-end_infinite]"
|
||||
style={{ verticalAlign: 'text-bottom' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDone: boolean }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [thinkingText, open]);
|
||||
|
||||
if (!thinkingText) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
|
||||
>
|
||||
<Brain className="size-3 text-amber-500 shrink-0" />
|
||||
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-32 overflow-y-auto italic"
|
||||
>
|
||||
{thinkingText}
|
||||
{!isDone && (
|
||||
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [sections]);
|
||||
|
||||
const activeSectionIdx = sections.reduce(
|
||||
(max, s) => (!s.isDone ? Math.max(max, s.sectionIndex) : max),
|
||||
-1,
|
||||
);
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="rounded-xl border bg-card shadow-sm p-4 prose prose-sm dark:prose-invert max-w-none">
|
||||
{title && (
|
||||
<h1 className="text-base font-bold text-center mb-4 text-foreground">{title}</h1>
|
||||
)}
|
||||
|
||||
{sections.map((sec) => (
|
||||
<div key={sec.sectionIndex} className="mb-4">
|
||||
<h2 className={cn(
|
||||
'text-sm font-semibold mb-1.5',
|
||||
sec.isDone ? 'text-foreground' : 'text-purple-500',
|
||||
)}>
|
||||
{sec.sectionTitle}
|
||||
</h2>
|
||||
|
||||
{/* 该章节的思考过程(可折叠) */}
|
||||
<SectionThinking thinkingText={sec.thinkingText} isDone={sec.isDone} />
|
||||
|
||||
{/* 正文内容 */}
|
||||
<p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0">
|
||||
{sec.text}
|
||||
{sec.sectionIndex === activeSectionIdx && !sec.isDone && <TypewriterCursor />}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
PauseCircle, CheckCircle2, RotateCcw, Search,
|
||||
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
|
||||
ProgressEvent, UserInterventionPayload,
|
||||
} from '../../types/ai-writing';
|
||||
import { MaterialsCard } from './MaterialsCard';
|
||||
import { MaterialsToolbar } from './MaterialsToolbar';
|
||||
import { OutlineCard } from './OutlineCard';
|
||||
import { ReviewCard } from './ReviewCard';
|
||||
|
||||
interface InterventionCardProps {
|
||||
pauseEvent: ProgressEvent;
|
||||
materials: AIMaterial[];
|
||||
outline: AIOutline | null;
|
||||
reviewResult: ReviewResult | null;
|
||||
draftTitle: string;
|
||||
revisionCount: number;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function InterventionCard({
|
||||
pauseEvent, materials, outline, reviewResult,
|
||||
draftTitle, revisionCount, onSubmit, disabled,
|
||||
}: InterventionCardProps) {
|
||||
const pausePoint = pauseEvent.pausePoint;
|
||||
|
||||
if (pausePoint === 'material_confirm') {
|
||||
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
|
||||
}
|
||||
|
||||
if (pausePoint === 'outline_confirm') {
|
||||
return (
|
||||
<OutlineConfirmCard
|
||||
outline={outline ?? pauseEvent.outline ?? null}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pausePoint === 'draft_confirm') {
|
||||
return (
|
||||
<DraftConfirmCard
|
||||
draftTitle={draftTitle}
|
||||
revisionCount={revisionCount}
|
||||
reviewResult={reviewResult}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pausePoint === 'review_confirm') {
|
||||
return (
|
||||
<ReviewConfirmCard
|
||||
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
|
||||
revisionCount={revisionCount}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
|
||||
等待用户确认({pausePoint})…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
|
||||
return new Set(issues.map((_, i) => i));
|
||||
}
|
||||
|
||||
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
|
||||
const selected = issues.filter((_, i) => selectedIndices.has(i));
|
||||
if (selected.length === 0) return '';
|
||||
return selected
|
||||
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface MaterialConfirmCardProps {
|
||||
materials: AIMaterial[];
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
() => new Set(materials.map((m) => m.id)),
|
||||
);
|
||||
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleReSearch() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasExtra = extraKeywords.length > 0;
|
||||
|
||||
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
|
||||
if (materials.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">未检索到任何素材</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MaterialsToolbar
|
||||
materials={materials}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAll={() => setSelectedIds(new Set())}
|
||||
onDeselectAll={() => setSelectedIds(new Set())}
|
||||
extraKeywords={extraKeywords}
|
||||
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
|
||||
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasExtra
|
||||
? <><Search className="size-3.5" />追加搜索</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleConfirm} disabled={disabled || submitting}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />无数据继续写作
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}} />
|
||||
|
||||
<MaterialsToolbar
|
||||
materials={materials}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
|
||||
onDeselectAll={() => setSelectedIds(new Set())}
|
||||
extraKeywords={extraKeywords}
|
||||
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
|
||||
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasExtra
|
||||
? <><Search className="size-3.5" />追加搜索</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface OutlineConfirmCardProps {
|
||||
outline: AIOutline | null;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
|
||||
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
|
||||
const [showReplan, setShowReplan] = useState(false);
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({
|
||||
action: 'confirm',
|
||||
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleReplan() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 're_outline', outlineFeedback: feedback.trim() });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">请确认大纲后继续写作</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
可直接点击章节标题或要点进行编辑,或要求重新规划大纲。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editedOutline
|
||||
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
|
||||
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
|
||||
|
||||
{showReplan && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">重新规划意见(可选)</label>
|
||||
<textarea
|
||||
rows={2} value={feedback} onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="描述大纲调整方向,例如:增加案例分析章节…"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
|
||||
</Button>
|
||||
{showReplan ? (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleReplan} disabled={disabled || submitting}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />提交重规划
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={() => setShowReplan(true)} disabled={disabled || submitting}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />重新规划…
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface DraftConfirmCardProps {
|
||||
draftTitle: string;
|
||||
revisionCount: number;
|
||||
reviewResult: ReviewResult | null;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
|
||||
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
|
||||
);
|
||||
|
||||
function toggleIssue(index: number) {
|
||||
setSelectedIssueIndices((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(index) ? next.delete(index) : next.add(index);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleApplyReview() {
|
||||
if (!reviewResult) return;
|
||||
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
|
||||
setSubmitting(true);
|
||||
await onSubmit({
|
||||
action: 'user_revise',
|
||||
userRevisionNotes: formattedNotes,
|
||||
selectedIssueIndices: [...selectedIssueIndices],
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{draftTitle ? `《${draftTitle}》` : '草稿'}
|
||||
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
|
||||
· 右侧可预览完整内容
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑审核结果(若已审核,支持勾选意见) */}
|
||||
{reviewResult && (
|
||||
<ReviewCard
|
||||
review={reviewResult}
|
||||
revisionCount={revisionCount}
|
||||
selectedIndices={hasReview ? selectedIssueIndices : undefined}
|
||||
onToggleIssue={hasReview ? toggleIssue : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 用户自定义修改意见 */}
|
||||
{showNotes && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">您的修改意见</label>
|
||||
<textarea
|
||||
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||||
onClick={() => submit('finalize')} disabled={disabled || submitting}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认定稿
|
||||
</Button>
|
||||
|
||||
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
|
||||
{hasReview && (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm"
|
||||
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
|
||||
onClick={handleApplyReview}
|
||||
disabled={disabled || submitting || selectedIssueIndices.size === 0}
|
||||
>
|
||||
<ClipboardEdit className="size-3.5" />
|
||||
按编辑审核意见修改
|
||||
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
{showNotes ? (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
|
||||
>
|
||||
<PenLine className="size-3.5" />按我的意见修改
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
|
||||
>
|
||||
<PenLine className="size-3.5" />给出修改意见…
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={() => submit('to_editor')} disabled={disabled || submitting}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />让编辑继续审核
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
|
||||
|
||||
interface ReviewConfirmCardProps {
|
||||
reviewResult: ReviewResult | null;
|
||||
revisionCount: number;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
|
||||
const [extraNotes, setExtraNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
|
||||
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
|
||||
);
|
||||
|
||||
function toggleIssue(index: number) {
|
||||
setSelectedIssueIndices((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(index) ? next.delete(index) : next.add(index);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
setSubmitting(true);
|
||||
const issueNotes = reviewResult
|
||||
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
|
||||
: '';
|
||||
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
|
||||
await onSubmit({
|
||||
action: 'accept_review',
|
||||
userRevisionNotes: combined || undefined,
|
||||
selectedIssueIndices: [...selectedIssueIndices],
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleForce() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">编辑认为文章需要修改</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewResult ? (
|
||||
<ReviewCard
|
||||
review={reviewResult}
|
||||
revisionCount={revisionCount}
|
||||
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
|
||||
onToggleIssue={hasIssues ? toggleIssue : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">补充修改指令(可选)</label>
|
||||
<textarea
|
||||
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
|
||||
placeholder="留空则只采纳已勾选意见;或在此补充您自己的修改方向…"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={handleAccept}
|
||||
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0 && !extraNotes.trim())}
|
||||
>
|
||||
<ClipboardEdit className="size-3.5" />按编辑意见修改
|
||||
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={handleForce} disabled={disabled || submitting}
|
||||
>
|
||||
<ShieldAlert className="size-3.5" />强制定稿
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ReviewIssue } from '../../types/ai-writing';
|
||||
|
||||
interface IssueListProps {
|
||||
issues: ReviewIssue[];
|
||||
selectedIndices?: Set<number>;
|
||||
onToggle?: (index: number) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
fact: '事实',
|
||||
logic: '逻辑',
|
||||
language: '语言',
|
||||
};
|
||||
|
||||
const CATEGORY_COLOR: Record<string, string> = {
|
||||
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
|
||||
logic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400',
|
||||
language: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
|
||||
};
|
||||
|
||||
export function IssueList({ issues, selectedIndices, onToggle }: IssueListProps) {
|
||||
if (issues.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
|
||||
}
|
||||
|
||||
const indexed = issues.map((issue, i) => ({ issue, index: i }));
|
||||
const major = indexed.filter(({ issue }) => issue.severity === 'major');
|
||||
const minor = indexed.filter(({ issue }) => issue.severity === 'minor');
|
||||
const selectable = selectedIndices !== undefined && onToggle !== undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{major.length > 0 && (
|
||||
<IssueGroup
|
||||
label="必须修改"
|
||||
labelClass="text-red-500"
|
||||
items={major}
|
||||
selectable={selectable}
|
||||
selectedIndices={selectedIndices}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
{minor.length > 0 && (
|
||||
<IssueGroup
|
||||
label="建议修改"
|
||||
labelClass="text-yellow-500"
|
||||
items={minor}
|
||||
selectable={selectable}
|
||||
selectedIndices={selectedIndices}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueGroupProps {
|
||||
label: string;
|
||||
labelClass: string;
|
||||
items: { issue: ReviewIssue; index: number }[];
|
||||
selectable: boolean;
|
||||
selectedIndices?: Set<number>;
|
||||
onToggle?: (index: number) => void;
|
||||
}
|
||||
|
||||
function IssueGroup({ label, labelClass, items, selectable, selectedIndices, onToggle }: IssueGroupProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
|
||||
{items.map(({ issue, index }) => {
|
||||
const isSelected = !selectable || (selectedIndices?.has(index) ?? true);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'rounded border bg-background px-2.5 py-2 space-y-1 text-xs transition-opacity',
|
||||
selectable ? 'cursor-pointer' : '',
|
||||
selectable && !isSelected ? 'opacity-40 border-border/30' : 'border-border/60',
|
||||
)}
|
||||
onClick={selectable ? () => onToggle?.(index) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{selectable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggle?.(index)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
|
||||
{CATEGORY_LABEL[issue.category] ?? issue.category}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate">{issue.location}</span>
|
||||
</div>
|
||||
<p className={cn('text-foreground leading-snug', selectable ? 'pl-5' : '')}>
|
||||
{issue.issue}
|
||||
</p>
|
||||
<p className={cn('text-muted-foreground leading-snug', selectable ? 'pl-5' : '')}>
|
||||
<span className="text-primary mr-1">→</span>
|
||||
{issue.suggestion}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildIntranetSourceUrl } from '@/core/config';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
|
||||
interface MaterialItemProps {
|
||||
material: AIMaterial;
|
||||
checked: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function relevanceColor(score: number): string {
|
||||
if (score >= 0.8) return 'bg-green-500';
|
||||
if (score >= 0.5) return 'bg-orange-400';
|
||||
return 'bg-red-400';
|
||||
}
|
||||
|
||||
function relevanceLabel(score: number): string {
|
||||
if (score >= 0.8) return '高相关';
|
||||
if (score >= 0.5) return '中相关';
|
||||
return '低相关';
|
||||
}
|
||||
|
||||
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const pct = Math.round(material.relevanceScore * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border text-xs transition-colors',
|
||||
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
|
||||
)}
|
||||
>
|
||||
{/* 头部行 */}
|
||||
<div className="flex items-start gap-2 px-2.5 py-2">
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(material.id)}
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-input accent-primary cursor-pointer"
|
||||
/>
|
||||
|
||||
{/* 标题 + 展开按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex flex-1 items-start gap-1.5 text-left"
|
||||
>
|
||||
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
|
||||
{material.title}
|
||||
</span>
|
||||
{expanded
|
||||
? <ChevronDown className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 元信息行 */}
|
||||
<div className="flex items-center gap-2 px-2.5 pb-2 pl-8">
|
||||
<span className="text-muted-foreground truncate max-w-[120px]">{material.source}</span>
|
||||
{material.publishedAt && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="text-muted-foreground shrink-0">{material.publishedAt}</span>
|
||||
</>
|
||||
)}
|
||||
{/* 相关度色条 */}
|
||||
<div className="flex flex-1 items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', relevanceColor(material.relevanceScore))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn('shrink-0', relevanceColor(material.relevanceScore).replace('bg-', 'text-'))}>
|
||||
{pct}%
|
||||
</span>
|
||||
<span className="text-muted-foreground/60 shrink-0">({relevanceLabel(material.relevanceScore)})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && (
|
||||
<div className="px-2.5 pb-2.5 pl-8 space-y-1.5 border-t">
|
||||
<p className="text-muted-foreground leading-relaxed pt-2 line-clamp-6">
|
||||
{material.content.slice(0, 300)}
|
||||
{material.content.length > 300 && '…'}
|
||||
</p>
|
||||
{material.recUuid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.open(buildIntranetSourceUrl(material.recUuid), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
查看原文
|
||||
<ExternalLink className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, FileText } from 'lucide-react';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
import { MaterialItem } from './MaterialItem';
|
||||
|
||||
interface MaterialsCardProps {
|
||||
materials: AIMaterial[];
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsCard({ materials, selectedIds, onToggle }: MaterialsCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
||||
{/* 折叠头 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-muted/40 transition-colors text-left"
|
||||
>
|
||||
<FileText className="size-3.5 text-blue-500 shrink-0" />
|
||||
<span className="flex-1">
|
||||
素材列表
|
||||
<span className="ml-1 text-muted-foreground font-normal">
|
||||
(共 {materials.length} 条,已选 {selectedIds.size} 条)
|
||||
</span>
|
||||
</span>
|
||||
{expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
{/* 列表 */}
|
||||
{expanded && (
|
||||
<div className="px-2.5 pb-2.5 space-y-1.5 border-t">
|
||||
{materials.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2 text-center">暂无素材</p>
|
||||
) : (
|
||||
materials.map((m) => (
|
||||
<MaterialItem
|
||||
key={m.id}
|
||||
material={m}
|
||||
checked={selectedIds.has(m.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
|
||||
interface MaterialsToolbarProps {
|
||||
materials: AIMaterial[];
|
||||
selectedIds: Set<string>;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
extraKeywords: string[];
|
||||
onAddKeyword: (kw: string) => void;
|
||||
onRemoveKeyword: (kw: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsToolbar({
|
||||
materials,
|
||||
selectedIds,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
extraKeywords,
|
||||
onAddKeyword,
|
||||
onRemoveKeyword,
|
||||
}: MaterialsToolbarProps) {
|
||||
const [kwInput, setKwInput] = useState('');
|
||||
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
|
||||
|
||||
function handleAddKeyword() {
|
||||
const kw = kwInput.trim();
|
||||
if (!kw || extraKeywords.includes(kw)) return;
|
||||
onAddKeyword(kw);
|
||||
setKwInput('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 全选/取消(无素材时隐藏) */}
|
||||
{materials.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
已选 {selectedIds.size} / {materials.length} 条素材
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={allSelected ? onDeselectAll : onSelectAll}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 补充关键词 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={kwInput}
|
||||
onChange={(e) => setKwInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
|
||||
placeholder="输入关键词后按 Enter 或点击 +"
|
||||
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleAddKeyword}
|
||||
disabled={!kwInput.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 已添加关键词 Tag 列表 */}
|
||||
{extraKeywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{extraKeywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
|
||||
>
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveKeyword(kw)}
|
||||
className="hover:text-destructive leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react';
|
||||
import { ListTree, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import { OutlineSection } from './OutlineSection';
|
||||
|
||||
interface OutlineCardProps {
|
||||
outline: AIOutline;
|
||||
onChange?: (updated: AIOutline) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(outline.title);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
|
||||
const editable = !readOnly && !!onChange;
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
if (trimmed && trimmed !== outline.title) {
|
||||
onChange?.({ ...outline, title: trimmed });
|
||||
} else {
|
||||
setTitleDraft(outline.title);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSection(i: number, updated: OutlineSectionType) {
|
||||
const sections = [...outline.sections];
|
||||
sections[i] = updated;
|
||||
onChange?.({ ...outline, sections });
|
||||
}
|
||||
|
||||
function removeSection(i: number) {
|
||||
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function reorderSections(from: number, to: number) {
|
||||
if (from === to) return;
|
||||
const sections = [...outline.sections];
|
||||
const [moved] = sections.splice(from, 1);
|
||||
sections.splice(to, 0, moved);
|
||||
onChange?.({ ...outline, sections });
|
||||
}
|
||||
|
||||
function addSection() {
|
||||
onChange?.({
|
||||
...outline,
|
||||
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
||||
<span className="text-xs font-medium flex-1">文章大纲</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{collapsed ? '展开' : '收起'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
{/* 文章标题 */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground shrink-0">标题:</span>
|
||||
{editingTitle && editable ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
|
||||
onClick={() => editable && setEditingTitle(true)}
|
||||
>
|
||||
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 章节列表 */}
|
||||
<div className="space-y-1.5">
|
||||
{outline.sections.map((section, i) => (
|
||||
<OutlineSection
|
||||
key={i}
|
||||
section={section}
|
||||
index={i}
|
||||
onChange={(updated) => updateSection(i, updated)}
|
||||
onRemove={editable ? () => removeSection(i) : undefined}
|
||||
readOnly={!editable}
|
||||
isDragging={dragIndex === i}
|
||||
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
|
||||
onDragStart={() => setDragIndex(i)}
|
||||
onDragEnter={() => setOverIndex(i)}
|
||||
onDrop={() => {
|
||||
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 添加章节 */}
|
||||
{editable && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
|
||||
onClick={addSection}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加章节
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, Plus, X, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
|
||||
interface OutlineSectionProps {
|
||||
section: OutlineSectionType;
|
||||
index: number;
|
||||
onChange: (updated: OutlineSectionType) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
// Drag-and-drop
|
||||
isDragging?: boolean;
|
||||
isOver?: boolean;
|
||||
onDragStart?: () => void;
|
||||
onDragEnter?: () => void;
|
||||
onDrop?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
export function OutlineSection({
|
||||
section, index, onChange, onRemove, readOnly,
|
||||
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
|
||||
}: OutlineSectionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
if (trimmed && trimmed !== section.sectionTitle) {
|
||||
onChange({ ...section, sectionTitle: trimmed });
|
||||
} else {
|
||||
setTitleDraft(section.sectionTitle);
|
||||
}
|
||||
}
|
||||
|
||||
function updateKeyPoint(i: number, value: string) {
|
||||
const next = [...section.keyPoints];
|
||||
next[i] = value;
|
||||
onChange({ ...section, keyPoints: next });
|
||||
}
|
||||
|
||||
function removeKeyPoint(i: number) {
|
||||
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addKeyPoint() {
|
||||
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!readOnly}
|
||||
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
|
||||
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
|
||||
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
|
||||
className={cn(
|
||||
'rounded border bg-background text-xs transition-all duration-150 select-none',
|
||||
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
|
||||
)}
|
||||
>
|
||||
{/* 章节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2">
|
||||
{!readOnly && (
|
||||
<GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab active:cursor-grabbing" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3.5 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 要点列表 */}
|
||||
{expanded && (
|
||||
<div className="px-8 pb-2.5 space-y-1 border-t">
|
||||
{section.keyPoints.map((kp, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 pt-1.5">
|
||||
<span className="text-muted-foreground/40 shrink-0">•</span>
|
||||
{readOnly ? (
|
||||
<span className="flex-1 text-muted-foreground">{kp}</span>
|
||||
) : (
|
||||
<input
|
||||
value={kp}
|
||||
onChange={(e) => updateKeyPoint(i, e.target.value)}
|
||||
placeholder="输入要点…"
|
||||
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加要点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||
|
||||
interface OutlineStreamingViewProps {
|
||||
text: string;
|
||||
thinkingText?: string;
|
||||
}
|
||||
|
||||
export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const thinkRef = useRef<HTMLPreElement>(null);
|
||||
const [thinkOpen, setThinkOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkOpen && thinkRef.current) {
|
||||
thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
|
||||
}
|
||||
}, [thinkingText, thinkOpen]);
|
||||
|
||||
if (!text && !thinkingText) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
{/* 思考过程区(仅在有思考内容时展示) */}
|
||||
{thinkingText && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
|
||||
>
|
||||
<Brain className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
思考过程
|
||||
</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{thinkOpen
|
||||
? <ChevronDown className="size-3.5" />
|
||||
: <ChevronRight className="size-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
{thinkOpen && (
|
||||
<pre
|
||||
ref={thinkRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
|
||||
>
|
||||
{thinkingText}
|
||||
{/* 思考仍在进行时显示光标 */}
|
||||
{!text && (
|
||||
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 正文流式区 */}
|
||||
{text && (
|
||||
<div className="rounded-xl border border-purple-200 dark:border-purple-900 bg-purple-50/50 dark:bg-purple-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
|
||||
<FileText className="size-3.5 text-purple-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
作家正在规划大纲…
|
||||
</span>
|
||||
<span className="ml-auto inline-flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1 rounded-full bg-purple-400 animate-bounce"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
|
||||
>
|
||||
{text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-purple-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ReviewResult } from '../../types/ai-writing';
|
||||
import { ScoreBar } from './ScoreBar';
|
||||
import { IssueList } from './IssueList';
|
||||
|
||||
interface ReviewCardProps {
|
||||
review: ReviewResult;
|
||||
revisionCount?: number;
|
||||
selectedIndices?: Set<number>;
|
||||
onToggleIssue?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ReviewCard({ review, revisionCount, selectedIndices, onToggleIssue }: ReviewCardProps) {
|
||||
const isPassed = review.verdict === 'pass';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 overflow-hidden text-xs">
|
||||
{/* 头部:综合评分 + 轮次 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">编辑审核结果</span>
|
||||
{revisionCount !== undefined && revisionCount > 0 && (
|
||||
<span className="text-muted-foreground">(第 {revisionCount} 次修改)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||||
isPassed
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{isPassed ? '通过' : '打回'}
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{review.overallScore}
|
||||
<span className="text-muted-foreground font-normal"> / 100</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分项评分 */}
|
||||
<div className="px-3 py-2.5 space-y-2 border-b">
|
||||
<ScoreBar label="事实准确性" score={review.factScore} />
|
||||
<ScoreBar label="逻辑结构" score={review.logicScore} />
|
||||
<ScoreBar label="语言文字" score={review.languageScore} />
|
||||
<p className="text-muted-foreground/60 text-[10px]">
|
||||
通过线:{review.passThreshold} 分
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 修改意见(支持勾选) */}
|
||||
{review.revisionNotes.length > 0 && (
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-muted-foreground font-medium">修改意见</p>
|
||||
{selectedIndices !== undefined && onToggleIssue && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
已选 {selectedIndices.size} / {review.revisionNotes.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<IssueList
|
||||
issues={review.revisionNotes}
|
||||
selectedIndices={selectedIndices}
|
||||
onToggle={onToggleIssue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StreamingReviewItem, ReviewIssue } from '../../types/ai-writing';
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
'fact' | 'logic' | 'language',
|
||||
{ label: string; order: number }
|
||||
> = {
|
||||
fact: { label: '事实准确性', order: 0 },
|
||||
logic: { label: '逻辑结构', order: 1 },
|
||||
language: { label: '语言文字', order: 2 },
|
||||
};
|
||||
|
||||
const CATEGORIES = (['fact', 'logic', 'language'] as const);
|
||||
|
||||
function ScoreBar({ score }: { score: number }) {
|
||||
const color =
|
||||
score >= 80 ? 'bg-green-400' :
|
||||
score >= 60 ? 'bg-amber-400' :
|
||||
'bg-red-400';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', color)}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold tabular-nums w-8 text-right">{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueRow({ issue }: { issue: ReviewIssue }) {
|
||||
return (
|
||||
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
|
||||
<span className={cn(
|
||||
'font-medium mr-1',
|
||||
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
|
||||
)}>
|
||||
{issue.severity === 'major' ? '严重' : '轻微'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{issue.issue}</span>
|
||||
{issue.suggestion && (
|
||||
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 流式打字机文本块(审核进行中的分类显示)
|
||||
function StreamingText({ item }: { item: StreamingReviewItem }) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [item.streamingText, item.thinkingText]);
|
||||
|
||||
const hasContent = item.streamingText || item.thinkingText;
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<p className="text-[11px] text-muted-foreground pl-5 italic">正在审阅…</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed mt-1 p-2 rounded bg-muted/40 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto"
|
||||
>
|
||||
{item.thinkingText && (
|
||||
<span className="italic text-amber-600/70 dark:text-amber-400/60">
|
||||
{item.thinkingText}
|
||||
</span>
|
||||
)}
|
||||
{item.streamingText}
|
||||
<span className="inline-block w-0.5 h-3 bg-orange-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewStreamingViewProps {
|
||||
items: StreamingReviewItem[];
|
||||
}
|
||||
|
||||
export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const doneItems = items.filter((it) => it.isDone);
|
||||
const allDone = doneItems.length === 3;
|
||||
const overallScore =
|
||||
doneItems.length > 0
|
||||
? Math.round(doneItems.reduce((s, it) => s + it.score, 0) / doneItems.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="rounded-xl border border-orange-200 dark:border-orange-900 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden shadow-sm">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-200/60 dark:border-orange-900/60 bg-orange-100/40 dark:bg-orange-900/30">
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
|
||||
编辑正在审核…
|
||||
</span>
|
||||
{!allDone && (
|
||||
<span className="ml-auto inline-flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1 rounded-full bg-orange-400 animate-bounce"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{allDone && (
|
||||
<span className="ml-auto text-xs text-orange-600 dark:text-orange-400 font-semibold tabular-nums">
|
||||
综合 {overallScore}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 逐项审核结果 */}
|
||||
<div className="divide-y divide-orange-100 dark:divide-orange-900/50">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
const item = items.find((it) => it.category === cat);
|
||||
const isDone = item?.isDone ?? false;
|
||||
const isStreaming = item != null && !item.isDone;
|
||||
const isPending = item == null;
|
||||
|
||||
return (
|
||||
<div key={cat} className="px-3 py-2 space-y-1.5">
|
||||
{/* 分类行 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
) : isStreaming ? (
|
||||
<Loader2 className="size-3.5 text-orange-400 animate-spin shrink-0" />
|
||||
) : (
|
||||
<span className="size-3.5 rounded-full border border-muted-foreground/30 shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
'text-xs font-medium',
|
||||
isPending ? 'text-muted-foreground' : 'text-foreground',
|
||||
)}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 审核进行中:流式打字机文本 */}
|
||||
{isStreaming && item && <StreamingText item={item} />}
|
||||
|
||||
{/* 审核完成:得分 + 问题列表 */}
|
||||
{isDone && item && (
|
||||
<>
|
||||
<ScoreBar score={item.score} />
|
||||
{item.issues.length > 0 ? (
|
||||
<div className="space-y-1 mt-1">
|
||||
{item.issues.slice(0, 3).map((iss, i) => (
|
||||
<IssueRow key={i} issue={iss} />
|
||||
))}
|
||||
{item.issues.length > 3 && (
|
||||
<p className="text-[11px] text-muted-foreground pl-2">
|
||||
+{item.issues.length - 3} 条意见…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-green-600 dark:text-green-400 pl-5">
|
||||
无问题
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RevisionSummary } from '../../types/ai-writing';
|
||||
|
||||
interface RevisionSummaryCardProps {
|
||||
summary: RevisionSummary;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
fact: '事实',
|
||||
logic: '逻辑',
|
||||
language: '语言',
|
||||
};
|
||||
|
||||
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
|
||||
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
|
||||
const wordDiff = newWordCount - oldWordCount;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200 dark:border-green-900/50 bg-green-50/50 dark:bg-green-950/20 overflow-hidden text-xs">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-green-200/60 dark:border-green-900/60 bg-green-100/40 dark:bg-green-900/30">
|
||||
<span className="font-medium text-green-700 dark:text-green-400">修改完成</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{newWordCount.toLocaleString()} 字
|
||||
{wordDiff !== 0 && (
|
||||
<span className={cn('ml-1', wordDiff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
|
||||
({wordDiff > 0 ? '+' : ''}{wordDiff})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
{/* 已修改 */}
|
||||
{revisedIssues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
已修改 {revisedIssues.length} 条意见
|
||||
</p>
|
||||
{revisedIssues.map((iss, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
|
||||
<span className={cn(
|
||||
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
|
||||
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
|
||||
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
|
||||
)}>
|
||||
{CATEGORY_LABEL[iss.category] ?? iss.category}
|
||||
</span>
|
||||
<span>{iss.issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已跳过 */}
|
||||
{skippedIssues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground font-medium flex items-center gap-1">
|
||||
<XCircle className="size-3.5" />
|
||||
跳过 {skippedIssues.length} 条意见
|
||||
</p>
|
||||
{skippedIssues.map((iss, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
|
||||
<span>{iss.issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revisedIssues.length === 0 && skippedIssues.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-1">已完成修改</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ScoreBarProps {
|
||||
label: string;
|
||||
score: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreBar({ label, score, className }: ScoreBarProps) {
|
||||
const clipped = Math.min(100, Math.max(0, score));
|
||||
|
||||
const color =
|
||||
clipped >= 80
|
||||
? 'bg-green-500'
|
||||
: clipped >= 60
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500';
|
||||
|
||||
const verdict =
|
||||
clipped >= 80 ? '优秀' : clipped >= 60 ? '需改进' : '不合格';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{clipped}
|
||||
<span className="text-muted-foreground/60 ml-1">({verdict})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', color)}
|
||||
style={{ width: `${clipped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProgressEvent } from '../../types/ai-writing';
|
||||
|
||||
type StepStatus = 'done' | 'running' | 'paused' | 'waiting' | 'error' | 'warning';
|
||||
|
||||
interface TimelineStepProps {
|
||||
event: ProgressEvent;
|
||||
isLast?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
|
||||
if (event.type === 'error') return 'error';
|
||||
if (event.type === 'notice') return 'warning';
|
||||
if (event.type === 'await_user') return 'paused';
|
||||
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
|
||||
if (event.type === 'step_start') return isLast ? 'running' : 'done';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
const AGENT_COLOR: Record<string, string> = {
|
||||
素材收集专家: 'text-blue-500',
|
||||
作家: 'text-purple-500',
|
||||
编辑: 'text-orange-500',
|
||||
系统: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
|
||||
const status = statusOf(event, isLast ?? true);
|
||||
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,两者都展示「标题 + 真实原因」
|
||||
const isAlert = event.type === 'error' || event.type === 'notice';
|
||||
const isError = event.type === 'error';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{/* 竖线 + 图标 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<StatusIcon status={status} />
|
||||
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className={cn('pb-4 flex-1 min-w-0', isLast && 'pb-0')}>
|
||||
{event.agentName && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold',
|
||||
AGENT_COLOR[event.agentName] ?? 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{event.agentName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isAlert ? (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 rounded-md border px-2.5 py-1.5',
|
||||
isError
|
||||
? 'border-red-200 bg-red-50/60 dark:border-red-900/40 dark:bg-red-950/20'
|
||||
: 'border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-950/20',
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
isError
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-amber-600 dark:text-amber-500',
|
||||
)}
|
||||
>
|
||||
{event.title || event.message || (isError ? '出错了' : '提示')}
|
||||
</p>
|
||||
{event.detail && (
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground break-words">
|
||||
{event.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">{event.message}</span>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: StepStatus }) {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return <CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />;
|
||||
case 'running':
|
||||
return <Loader2 className="size-4 text-blue-500 shrink-0 mt-0.5 animate-spin" />;
|
||||
case 'paused':
|
||||
return <PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="size-4 text-red-500 shrink-0 mt-0.5" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="size-4 text-amber-500 shrink-0 mt-0.5" />;
|
||||
default:
|
||||
return <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WritingStatus } from '../../types/ai-writing';
|
||||
|
||||
interface WritingSkeletonCardProps {
|
||||
status: WritingStatus;
|
||||
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
|
||||
researchProgress?: string;
|
||||
}
|
||||
|
||||
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('h-3 rounded-md bg-muted animate-pulse', className)} />
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingDots({ color = 'bg-blue-400' }: { color?: string }) {
|
||||
return (
|
||||
<span className="inline-flex gap-1 items-center">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn('size-1.5 rounded-full animate-bounce', color)}
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 素材收集骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function MaterialsSkeleton({ researchProgress }: { researchProgress?: string }) {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{/* 标题行:展示素材收集专家的实时进度 */}
|
||||
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
|
||||
<ThinkingDots color="bg-blue-400" />
|
||||
<span>{researchProgress || '素材收集专家搜索中…'}</span>
|
||||
</div>
|
||||
|
||||
{/* 关键词标签占位 */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{[60, 80, 50].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-5 rounded-full bg-blue-100/60 dark:bg-blue-900/30 animate-pulse"
|
||||
style={{ width: `${w}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 素材卡片占位 */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-start gap-2">
|
||||
<SkeletonLine className={cn('flex-1 h-3.5', i % 3 === 0 ? 'w-4/5' : i % 3 === 1 ? 'w-3/4' : 'w-2/3')} />
|
||||
</div>
|
||||
{/* 来源 + 时间 + 相关度 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonLine className="w-20 h-2" />
|
||||
<SkeletonLine className="w-16 h-2" />
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-200/60 dark:bg-blue-700/40 animate-pulse"
|
||||
style={{ width: `${55 + i * 8}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 摘要行 */}
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-5/6' : 'w-4/5')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function OutlineSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||||
<ThinkingDots color="bg-purple-400" />
|
||||
<span>作家规划大纲中…</span>
|
||||
</div>
|
||||
|
||||
{/* 文章标题占位 */}
|
||||
<SkeletonLine className="w-3/5 h-4 mx-auto" />
|
||||
|
||||
{/* 章节列表 */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-start gap-2 pl-1">
|
||||
<div className="mt-1 size-2 shrink-0 rounded-sm bg-purple-200/70 dark:bg-purple-800/40 animate-pulse" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<SkeletonLine className={cn('h-3', i % 2 === 0 ? 'w-2/5' : 'w-1/2')} />
|
||||
<SkeletonLine className="w-3/5 h-2" />
|
||||
{i % 2 === 0 && <SkeletonLine className="w-2/5 h-2" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 正文写作骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function DraftSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||||
<ThinkingDots color="bg-purple-400" />
|
||||
<span>作家写作中…</span>
|
||||
</div>
|
||||
|
||||
{/* 标题占位 */}
|
||||
<SkeletonLine className="w-2/5 h-5 mx-auto" />
|
||||
|
||||
{/* 章节内容 */}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<SkeletonLine className="w-1/4 h-3.5" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-11/12')} />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-3/4 h-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 编辑审核骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function ReviewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
|
||||
<ThinkingDots color="bg-orange-400" />
|
||||
<span>编辑审核中…</span>
|
||||
</div>
|
||||
|
||||
{/* 综合评分占位 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonLine className="w-24 h-3" />
|
||||
<SkeletonLine className="w-12 h-4" />
|
||||
</div>
|
||||
|
||||
{/* 分项评分条 */}
|
||||
{['事实准确度', '逻辑结构', '语言质量'].map((label, i) => (
|
||||
<div key={label} className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<SkeletonLine className="w-8 h-3" />
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-orange-200/60 dark:bg-orange-700/40 animate-pulse"
|
||||
style={{ width: `${55 + i * 10}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 意见条目占位 */}
|
||||
<div className="space-y-2 pt-1 border-t">
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-5/6 h-2" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-4/5 h-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 按意见修改骨架屏 ──────────────────────────────────────────────────────────
|
||||
|
||||
function RevisingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||||
<ThinkingDots color="bg-purple-400" />
|
||||
<span>作家按意见修改中…</span>
|
||||
</div>
|
||||
<SkeletonLine className="w-2/5 h-5 mx-auto" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<SkeletonLine className="w-1/4 h-3.5" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-full')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 状态映射 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// researching / awaiting_material_confirm 由 WritingSkeletonCard 单独处理(需透传进度文案)
|
||||
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
|
||||
// 实际运行状态
|
||||
writing_outline: OutlineSkeleton,
|
||||
writing_draft: DraftSkeleton,
|
||||
reviewing: ReviewSkeleton,
|
||||
revising: RevisingSkeleton,
|
||||
// 过渡等待状态(用户提交后、后端事件到达前的空白期)
|
||||
awaiting_outline_confirm: OutlineSkeleton, // 过渡到写作
|
||||
awaiting_draft_confirm: DraftSkeleton, // 提交修改意见或编辑审核后
|
||||
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
|
||||
};
|
||||
|
||||
export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) {
|
||||
// 素材收集阶段单独渲染,以便透传实时进度文案
|
||||
if (status === 'researching' || status === 'awaiting_material_confirm') {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<MaterialsSkeleton researchProgress={researchProgress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Skeleton = SKELETON_MAP[status];
|
||||
if (!Skeleton) return null;
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<Skeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,20 @@ export function CanvasArtifactPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// While streaming but no partial content yet — show a loading placeholder
|
||||
// so the template picker doesn't flash during generation.
|
||||
if (isStreaming && !artifact) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">AI 正在生成…</p>
|
||||
<p className="text-xs">内容将在右侧显示</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10">
|
||||
|
||||
@@ -3,10 +3,9 @@ import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
||||
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import CodeMirror, { type ViewUpdate } from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
|
||||
@@ -1,18 +1,171 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
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;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
||||
const { handleTextPointerUp } = useCanvasSelection();
|
||||
interface PanelPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
if (!content.fullMarkdown) {
|
||||
/** 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 { artifact, setArtifact } = useCanvasGraphContext();
|
||||
const { writingSelection } = useCanvasUIContext();
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
// ── 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;
|
||||
setShowScrollBtn(false);
|
||||
return;
|
||||
}
|
||||
if (!autoScrollRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [isStreaming, content.fullMarkdown]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!isStreaming) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
setShowScrollBtn(!atBottom);
|
||||
}, [isStreaming]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
autoScrollRef.current = true;
|
||||
setShowScrollBtn(false);
|
||||
}, []);
|
||||
|
||||
// ── 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">
|
||||
@@ -22,125 +175,146 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full overflow-y-auto"
|
||||
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
|
||||
>
|
||||
<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 (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{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>
|
||||
),
|
||||
}}
|
||||
// ── 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}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
<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 indent-[2em]">
|
||||
{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>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* 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,307 @@
|
||||
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 [computedPos, setComputedPos] = useState<{
|
||||
left: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const MARGIN = 8;
|
||||
const GAP = 6;
|
||||
const MIN_HEIGHT = 160;
|
||||
const safeX = Math.min(Math.max(pos.x, MARGIN), window.innerWidth - panelWidth - MARGIN);
|
||||
const spaceBelow = window.innerHeight - pos.y - MARGIN;
|
||||
const spaceAbove = pos.y - MARGIN;
|
||||
if (spaceBelow >= spaceAbove) {
|
||||
// More space below — anchor top at selection bottom, grow downward
|
||||
setComputedPos({
|
||||
left: safeX,
|
||||
top: pos.y + GAP,
|
||||
maxHeight: Math.max(MIN_HEIGHT, spaceBelow - GAP),
|
||||
});
|
||||
} else {
|
||||
// More space above — anchor bottom at selection top, grow upward
|
||||
setComputedPos({
|
||||
left: safeX,
|
||||
bottom: window.innerHeight - pos.y + GAP,
|
||||
maxHeight: Math.max(MIN_HEIGHT, spaceAbove - GAP),
|
||||
});
|
||||
}
|
||||
}, [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
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: computedPos?.left ?? pos.x,
|
||||
...(computedPos?.bottom !== undefined
|
||||
? { bottom: computedPos.bottom }
|
||||
: { top: computedPos?.top ?? (pos.y + 6) }),
|
||||
maxHeight: computedPos?.maxHeight,
|
||||
width: panelWidth,
|
||||
zIndex: 9999,
|
||||
visibility: computedPos ? "visible" : "hidden",
|
||||
}}
|
||||
className="flex flex-col 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 overflow-y-auto flex-1 min-h-0">
|
||||
|
||||
{/* 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 { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
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 { useCanvasStream } from "../../hooks/useCanvasStream";
|
||||
import type { CanvasMessage } from "../../types/thread";
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
|
||||
import {
|
||||
buildMessageWithSelection,
|
||||
@@ -22,174 +53,15 @@ import {
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ClarificationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type CanvasMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
const MODE_LABELS: Record<CanvasMode, string> = {
|
||||
flash: "快速",
|
||||
thinking: "推理",
|
||||
pro: "深入",
|
||||
ultra: "超级",
|
||||
};
|
||||
|
||||
type ClarificationPayload = {
|
||||
question?: string;
|
||||
context?: string | null;
|
||||
options?: ClarificationOption[];
|
||||
allow_custom?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stripThinkBlocks(text: string): string {
|
||||
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
||||
}
|
||||
|
||||
function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null {
|
||||
const c = msg.additional_kwargs?.clarification;
|
||||
if (typeof c !== "object" || c === null) return null;
|
||||
return c as ClarificationPayload;
|
||||
}
|
||||
|
||||
function getTextContent(msg: CanvasMessage): string {
|
||||
return typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClarificationCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ClarificationCard({
|
||||
message,
|
||||
disabled,
|
||||
onSubmit,
|
||||
}: {
|
||||
message: CanvasMessage;
|
||||
disabled: boolean;
|
||||
onSubmit: (answer: string) => void;
|
||||
}) {
|
||||
const payload = getClarificationPayload(message)!;
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const options = (payload.options ?? []).filter(
|
||||
(o) => typeof o.label === "string" && o.label.trim(),
|
||||
);
|
||||
|
||||
const busy = disabled || submitted;
|
||||
|
||||
function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setSubmitted(true);
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
|
||||
const fallback = stripThinkBlocks(getTextContent(message));
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-xs font-medium">需要你的确认</div>
|
||||
{payload.context ? (
|
||||
<p className="text-muted-foreground text-sm leading-6">{payload.context}</p>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 font-medium">
|
||||
{payload.question || fallback}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.id || option.label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={busy}
|
||||
onClick={() => submitAnswer(`我选择:${option.label}`)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col items-start gap-1">
|
||||
<span className="text-sm">{option.label}</span>
|
||||
{option.description ? (
|
||||
<span className="text-muted-foreground text-xs font-normal leading-5">
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.allow_custom !== false ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Textarea
|
||||
value={customAnswer}
|
||||
onChange={(e) => setCustomAnswer(e.currentTarget.value)}
|
||||
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
|
||||
disabled={busy}
|
||||
className="min-h-20 resize-none text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !customAnswer.trim()}
|
||||
onClick={() => submitAnswer(customAnswer)}
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
发送回答
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiMessageContent — renders a single AI message bubble
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AiMessageContent({
|
||||
message,
|
||||
isActiveStream,
|
||||
isStreaming,
|
||||
onClarificationSubmit,
|
||||
}: {
|
||||
message: CanvasMessage;
|
||||
isActiveStream: boolean;
|
||||
isStreaming: boolean;
|
||||
onClarificationSubmit: (answer: string) => void;
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
|
||||
const clarification = getClarificationPayload(message);
|
||||
|
||||
if (clarification) {
|
||||
return (
|
||||
<ClarificationCard
|
||||
message={message}
|
||||
disabled={isStreaming}
|
||||
onSubmit={onClarificationSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const displayText = stripThinkBlocks(getTextContent(message));
|
||||
if (!displayText) return null;
|
||||
|
||||
return (
|
||||
<MarkdownContent
|
||||
content={displayText}
|
||||
isLoading={isActiveStream}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChatWelcome
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,20 +84,47 @@ 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 { messages, isStreaming, artifact } = useCanvasGraphContext();
|
||||
const { sendMessage } = useCanvasStream();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const { thread, sendMessage, threadId } = useCanvasStreamContext();
|
||||
const { modelName, setModelName } = useCanvasThreadContext();
|
||||
const { selection, clearSelection } = useCanvasSelection();
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { models } = useModels();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [mode, setMode] = useState<CanvasMode>("pro");
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
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);
|
||||
@@ -252,14 +151,13 @@ export function CanvasChatPanel() {
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Scroll to bottom whenever messages change or streaming state updates
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, isStreaming]);
|
||||
|
||||
async function handleSend() {
|
||||
if (isStreaming) {
|
||||
await thread.stop();
|
||||
return;
|
||||
}
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
if (!text) return;
|
||||
setInput("");
|
||||
|
||||
const finalText = hasSelection
|
||||
@@ -267,7 +165,7 @@ export function CanvasChatPanel() {
|
||||
: text;
|
||||
|
||||
clearSelection();
|
||||
await sendMessage(finalText);
|
||||
await sendMessage(finalText, { mode });
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -277,136 +175,232 @@ export function CanvasChatPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the last AI message — used to decide which message gets streaming animation
|
||||
const lastAiIndex = (() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i]!.type !== "human") return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const handleClarificationSubmit = useCallback(
|
||||
async (answer: string) => {
|
||||
await sendMessage(answer, { mode });
|
||||
},
|
||||
[sendMessage, mode],
|
||||
);
|
||||
|
||||
// Whether the last AI message has any visible text (not just think blocks / empty)
|
||||
const lastAiHasVisibleContent = (() => {
|
||||
if (lastAiIndex < 0) return false;
|
||||
const msg = messages[lastAiIndex]!;
|
||||
if (getClarificationPayload(msg)) return true;
|
||||
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
|
||||
})();
|
||||
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
|
||||
// BaseStream<CanvasThreadValues> — structurally compatible for message rendering
|
||||
// since MessageList only reads thread.messages, isLoading, and isThreadLoading.
|
||||
const threadForList = thread as unknown as BaseStream<AgentThreadState>;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{hasMessages ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{messages.map((msg, i) => {
|
||||
const isHuman = msg.type === "human";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id ?? i}
|
||||
className={`flex gap-2 ${isHuman ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isHuman && (
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHuman ? (
|
||||
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
|
||||
{getTextContent(msg)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-[85%] min-w-0">
|
||||
<AiMessageContent
|
||||
message={msg}
|
||||
isActiveStream={isStreaming && i === lastAiIndex}
|
||||
isStreaming={isStreaming}
|
||||
onClarificationSubmit={(answer) => void sendMessage(answer)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isHuman && (
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
|
||||
<User className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{isStreaming && !lastAiHasVisibleContent && (
|
||||
<div className="flex gap-2 justify-start">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">正在生成…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
{hasMessages || isStreaming || thread.isThreadLoading ? (
|
||||
<MessageList
|
||||
className="size-full"
|
||||
threadId={threadId ?? "canvas"}
|
||||
thread={threadForList}
|
||||
paddingBottom={0}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
) : (
|
||||
<ChatWelcome />
|
||||
)}
|
||||
</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
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
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()}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import {
|
||||
@@ -6,17 +6,23 @@ import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
|
||||
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
|
||||
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
|
||||
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
|
||||
|
||||
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
|
||||
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
|
||||
|
||||
type LeftMode = "chat" | "writing";
|
||||
|
||||
export function CanvasResizableLayout() {
|
||||
const { isChatCollapsed } = useCanvasUIContext();
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const [leftMode, setLeftMode] = useState<LeftMode>("chat");
|
||||
|
||||
useEffect(() => {
|
||||
layoutRef.current?.setLayout(
|
||||
@@ -25,6 +31,7 @@ export function CanvasResizableLayout() {
|
||||
}, [isChatCollapsed]);
|
||||
|
||||
return (
|
||||
<CanvasStreamProvider>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
@@ -37,9 +44,29 @@ export function CanvasResizableLayout() {
|
||||
minSize={0}
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
className="overflow-hidden"
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
<CanvasChatPanel />
|
||||
{/* 模式切换标签栏 */}
|
||||
<div className="flex shrink-0 border-b bg-muted/30">
|
||||
{(["chat", "writing"] as LeftMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setLeftMode(mode)}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-xs font-medium transition-colors",
|
||||
leftMode === mode
|
||||
? "border-b-2 border-primary text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{mode === "chat" ? "对话" : "AI 写作"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
@@ -54,5 +81,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,
|
||||
];
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
import { countWords } from '../utils/wordCount';
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
AIWritingState,
|
||||
AIMaterial,
|
||||
AIOutline,
|
||||
CompletedIntervention,
|
||||
ProgressEvent,
|
||||
ReviewResult,
|
||||
StreamingDraftSection,
|
||||
StreamingReviewItem,
|
||||
UserInterventionPayload,
|
||||
WritingStatus,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
const INITIAL_STATE: AIWritingState = {
|
||||
sessionId: null,
|
||||
status: 'idle',
|
||||
isPaused: false,
|
||||
progressEvents: [],
|
||||
materials: [],
|
||||
outline: null,
|
||||
currentDraftMarkdown: '',
|
||||
currentDraftTitle: '',
|
||||
reviewResult: null,
|
||||
revisionCount: 0,
|
||||
currentPause: null,
|
||||
completedInterventions: [],
|
||||
error: null,
|
||||
streamingDraftTitle: '',
|
||||
streamingDraftSections: [],
|
||||
outlineStreamingText: '',
|
||||
outlineThinkingText: '',
|
||||
researchProgress: '',
|
||||
streamingReviewItems: [],
|
||||
revisionSummary: null,
|
||||
pendingRevisionContext: null,
|
||||
};
|
||||
|
||||
export function useAIWriting() {
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const unsubRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const handleEvent = useCallback(
|
||||
(event: ProgressEvent) => {
|
||||
setState((prev) => {
|
||||
const base: AIWritingState = {
|
||||
...prev,
|
||||
progressEvents: [...prev.progressEvents, event],
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case 'step_start':
|
||||
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
|
||||
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
|
||||
return { ...base, status: 'reviewing' };
|
||||
}
|
||||
return base;
|
||||
|
||||
case 'step_done':
|
||||
return base;
|
||||
|
||||
case 'research_progress':
|
||||
// 素材收集专家实时进度:仅更新最新提示文案
|
||||
return { ...base, researchProgress: event.message ?? '' };
|
||||
|
||||
case 'resumed':
|
||||
return { ...base, currentPause: null };
|
||||
|
||||
case 'materials_ready':
|
||||
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹);进度文案已完成可清空
|
||||
return {
|
||||
...base,
|
||||
materials: (event.materials ?? []) as AIMaterial[],
|
||||
researchProgress: '',
|
||||
};
|
||||
|
||||
case 'outline_chunk': {
|
||||
const chunkText = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
return {
|
||||
...prev,
|
||||
status: 'writing_outline',
|
||||
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText,
|
||||
outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText,
|
||||
};
|
||||
}
|
||||
|
||||
case 'outline_ready':
|
||||
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
|
||||
return {
|
||||
...base,
|
||||
outline: (event.outline ?? null) as AIOutline | null,
|
||||
outlineStreamingText: '',
|
||||
outlineThinkingText: '',
|
||||
};
|
||||
|
||||
case 'draft_chunk': {
|
||||
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
|
||||
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
|
||||
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
|
||||
const existing = sections.find((s) => s.sectionIndex === idx);
|
||||
if (existing) {
|
||||
if (isThinking) existing.thinkingText += text;
|
||||
else existing.text += text;
|
||||
existing.isDone = done;
|
||||
} else {
|
||||
sections.push({
|
||||
sectionIndex: idx,
|
||||
sectionTitle: title,
|
||||
text: isThinking ? '' : text,
|
||||
thinkingText: isThinking ? text : '',
|
||||
isDone: done,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
status: 'writing_draft',
|
||||
streamingDraftSections: sections,
|
||||
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
|
||||
};
|
||||
}
|
||||
|
||||
case 'review_chunk': {
|
||||
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
// 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留
|
||||
const isNewCycle = prev.status !== 'reviewing';
|
||||
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
|
||||
let item = items.find((it) => it.category === category);
|
||||
if (!item) {
|
||||
item = {
|
||||
category, isDone: false,
|
||||
streamingText: '', thinkingText: '',
|
||||
score: 0, issues: [],
|
||||
};
|
||||
items.push(item);
|
||||
}
|
||||
if (isThinking) item.thinkingText += text;
|
||||
else item.streamingText += text;
|
||||
return { ...prev, status: 'reviewing', streamingReviewItems: items };
|
||||
}
|
||||
|
||||
case 'review_item_done': {
|
||||
const category = event.reviewItemCategory ?? 'fact';
|
||||
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
|
||||
const item = items.find((it) => it.category === category);
|
||||
if (item) {
|
||||
item.isDone = true;
|
||||
item.score = event.reviewItemScore ?? 80;
|
||||
item.issues = event.reviewItemIssues ?? [];
|
||||
} else {
|
||||
// 兜底:没收到任何 chunk 直接来 done
|
||||
items.push({
|
||||
category, isDone: true,
|
||||
streamingText: '', thinkingText: '',
|
||||
score: event.reviewItemScore ?? 80,
|
||||
issues: event.reviewItemIssues ?? [],
|
||||
});
|
||||
}
|
||||
return { ...prev, status: 'reviewing', streamingReviewItems: items };
|
||||
}
|
||||
|
||||
case 'draft_ready': {
|
||||
const newMarkdown = event.draftMarkdown ?? '';
|
||||
const newWordCount = countWords(newMarkdown);
|
||||
const summary = prev.pendingRevisionContext
|
||||
? {
|
||||
revisedIssues: prev.pendingRevisionContext.submittedIssues,
|
||||
skippedIssues: prev.pendingRevisionContext.skippedIssues,
|
||||
oldWordCount: prev.pendingRevisionContext.oldWordCount,
|
||||
newWordCount,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
...base,
|
||||
currentDraftMarkdown: newMarkdown,
|
||||
currentDraftTitle: event.draftTitle ?? '',
|
||||
revisionCount: event.revisionCount ?? 0,
|
||||
streamingDraftSections: [],
|
||||
streamingDraftTitle: '',
|
||||
revisionSummary: summary,
|
||||
pendingRevisionContext: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'review_ready':
|
||||
return {
|
||||
...base,
|
||||
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
|
||||
};
|
||||
|
||||
case 'await_user': {
|
||||
const pp = event.pausePoint;
|
||||
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
|
||||
// when queue is empty. If we already have this pause set, nothing needs to change.
|
||||
if (prev.currentPause?.pausePoint === pp) return prev;
|
||||
const pauseStatus = pp
|
||||
? (`awaiting_${pp}` as AIWritingState['status'])
|
||||
: prev.status;
|
||||
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
|
||||
const extra: Partial<AIWritingState> = {};
|
||||
if (pp === 'material_confirm' && event.materials?.length) {
|
||||
extra.materials = event.materials as AIMaterial[];
|
||||
}
|
||||
if (pp === 'outline_confirm' && event.outline) {
|
||||
extra.outline = event.outline as AIOutline;
|
||||
}
|
||||
return { ...base, ...extra, status: pauseStatus, currentPause: event };
|
||||
}
|
||||
|
||||
case 'done':
|
||||
return { ...base, status: 'done', currentPause: null };
|
||||
|
||||
case 'error': {
|
||||
// error 事件携带 title(如「模型错误」)+ detail(真实原因),拼成底部状态栏文案
|
||||
const msg = event.detail
|
||||
? `${event.title ?? '出错了'}:${event.detail}`
|
||||
: (event.message ?? '未知错误');
|
||||
return { ...base, status: 'error', error: msg };
|
||||
}
|
||||
|
||||
// notice:非致命告警(ES 波动 / 模型兜底),仅追加到时间线,不改变写作状态
|
||||
case 'notice':
|
||||
return base;
|
||||
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
});
|
||||
|
||||
// 草稿实时同步到右侧 Artifact 面板
|
||||
if (event.type === 'draft_ready' && event.draftMarkdown) {
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: event.draftTitle ?? '草稿',
|
||||
fullMarkdown: event.draftMarkdown,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[setArtifact],
|
||||
);
|
||||
|
||||
const startWriting = useCallback(
|
||||
async (request: AIWritingRequest) => {
|
||||
if (
|
||||
state.status !== 'idle' &&
|
||||
state.status !== 'done' &&
|
||||
state.status !== 'error'
|
||||
)
|
||||
return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setState({ ...INITIAL_STATE, status: 'researching' });
|
||||
|
||||
try {
|
||||
const sessionId = await startWritingSession(request);
|
||||
setState((prev) => ({ ...prev, sessionId }));
|
||||
unsubRef.current = subscribeToProgress(
|
||||
sessionId,
|
||||
handleEvent,
|
||||
abortRef.current.signal,
|
||||
);
|
||||
} catch (err) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: String(err),
|
||||
}));
|
||||
}
|
||||
},
|
||||
[state.status, handleEvent],
|
||||
);
|
||||
|
||||
const submitIntervention = useCallback(
|
||||
async (payload: UserInterventionPayload) => {
|
||||
if (!state.sessionId || !state.currentPause) return;
|
||||
|
||||
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接"
|
||||
if (state.isPaused) {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
unsubRef.current = subscribeToProgress(
|
||||
state.sessionId,
|
||||
handleEvent,
|
||||
abortRef.current.signal,
|
||||
);
|
||||
setState((prev) => ({ ...prev, isPaused: false }));
|
||||
}
|
||||
|
||||
const pausePoint = state.currentPause.pausePoint;
|
||||
const record: CompletedIntervention = {
|
||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||
action: payload.action,
|
||||
};
|
||||
if (pausePoint === 'material_confirm') {
|
||||
record.materials = state.materials;
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
record.outline = state.outline ?? undefined;
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
record.draftTitle = state.currentDraftTitle;
|
||||
record.draftWordCount = countWords(state.currentDraftMarkdown);
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
record.reviewResult = state.reviewResult ?? undefined;
|
||||
}
|
||||
|
||||
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
|
||||
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
|
||||
const isApplyingReview =
|
||||
payload.selectedIssueIndices !== undefined &&
|
||||
state.reviewResult != null;
|
||||
if (isApplyingReview && state.reviewResult) {
|
||||
const allIssues = state.reviewResult.revisionNotes;
|
||||
const selectedSet = new Set(payload.selectedIssueIndices!);
|
||||
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
|
||||
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
|
||||
pendingRevisionContext = {
|
||||
submittedIssues,
|
||||
skippedIssues,
|
||||
oldWordCount: countWords(state.currentDraftMarkdown),
|
||||
};
|
||||
}
|
||||
|
||||
// Optimistic next-status so the correct skeleton shows immediately after
|
||||
// the user clicks confirm, without waiting for the first SSE chunk.
|
||||
let nextStatus: WritingStatus = state.status;
|
||||
if (pausePoint === 'material_confirm') {
|
||||
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
if (payload.action === 'finalize') nextStatus = 'done';
|
||||
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
|
||||
else nextStatus = 'revising'; // user_revise
|
||||
} else if (pausePoint === 'review_confirm') {
|
||||
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
|
||||
}
|
||||
|
||||
try {
|
||||
await resumeWritingApi(state.sessionId, payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentPause: null,
|
||||
status: nextStatus,
|
||||
// Clear stale streaming state so new-cycle detection in chunk handlers works correctly
|
||||
...(nextStatus === 'researching' ? { researchProgress: '' } : {}),
|
||||
...(nextStatus === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
|
||||
...(nextStatus === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
...(nextStatus === 'reviewing' ? { streamingReviewItems: [] } : {}),
|
||||
...(nextStatus === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
completedInterventions: [...prev.completedInterventions, record],
|
||||
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
|
||||
}));
|
||||
} catch (err) {
|
||||
setState((prev) => ({ ...prev, error: String(err) }));
|
||||
}
|
||||
},
|
||||
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
|
||||
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
|
||||
);
|
||||
|
||||
const pauseWriting = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
unsubRef.current?.();
|
||||
setState((prev) => ({ ...prev, isPaused: true }));
|
||||
}, []);
|
||||
|
||||
const resumeWriting = useCallback(() => {
|
||||
if (!state.sessionId) return;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
unsubRef.current = subscribeToProgress(
|
||||
state.sessionId,
|
||||
handleEvent,
|
||||
abortRef.current.signal,
|
||||
);
|
||||
setState((prev) => ({ ...prev, isPaused: false }));
|
||||
}, [state.sessionId, handleEvent]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
unsubRef.current?.();
|
||||
setArtifact(null);
|
||||
setState(INITIAL_STATE);
|
||||
}, [setArtifact]);
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||
|
||||
// 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容
|
||||
useEffect(() => {
|
||||
const s = state.status;
|
||||
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
|
||||
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
|
||||
saveAIWritingTranscript(state.sessionId, {
|
||||
progressEvents: state.progressEvents,
|
||||
completedInterventions: state.completedInterventions,
|
||||
}).catch(() => {});
|
||||
// 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.status, state.sessionId]);
|
||||
|
||||
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
listAIWritingSessions,
|
||||
renameAIWritingSession,
|
||||
deleteAIWritingSession,
|
||||
} from '../api/ai-writing-sessions';
|
||||
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
|
||||
|
||||
export function useAIWritingSessions() {
|
||||
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listAIWritingSessions();
|
||||
setSessions(data);
|
||||
} catch {
|
||||
// silently ignore — history is non-critical
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rename = useCallback(async (id: string, title: string) => {
|
||||
try {
|
||||
const updated = await renameAIWritingSession(id, title);
|
||||
setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const remove = useCallback(async (id: string) => {
|
||||
try {
|
||||
await deleteAIWritingSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { sessions, loading, refresh, rename, remove };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
listArticleTypes,
|
||||
createArticleType,
|
||||
updateArticleType,
|
||||
deleteArticleType,
|
||||
} from '../api/ai-writing-article-types';
|
||||
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
|
||||
|
||||
export function useArticleTypes() {
|
||||
const [articleTypes, setArticleTypes] = useState<ArticleType[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listArticleTypes();
|
||||
setArticleTypes(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const create = useCallback(async (data: CreateArticleTypeRequest): Promise<ArticleType> => {
|
||||
const created = await createArticleType(data);
|
||||
setArticleTypes((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order));
|
||||
return created;
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> => {
|
||||
const updated = await updateArticleType(id, data);
|
||||
setArticleTypes((prev) =>
|
||||
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
|
||||
);
|
||||
return updated;
|
||||
}, []);
|
||||
|
||||
const remove = useCallback(async (id: string): Promise<void> => {
|
||||
await deleteArticleType(id);
|
||||
setArticleTypes((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return { articleTypes, loading, error, refresh, create, update, remove };
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
// Canvas-specific variant of useThreadStream (core/threads/hooks.ts).
|
||||
// Copied and adapted for canvas_agent so canvas writing-mode interactions
|
||||
// can be customized independently from the main lead_agent flow.
|
||||
|
||||
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getCanvasAPIClient } from "@/core/api/canvas-client";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
|
||||
import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
|
||||
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
|
||||
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
|
||||
import type { StreamingArtifactPartial } from "../api/runs";
|
||||
import type { CanvasArtifact } from "../types/artifact";
|
||||
import type { CanvasThreadValues } from "../types/thread";
|
||||
import { getCurrentArtifactContent } from "../utils/artifact";
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) return error;
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const message = Reflect.get(error, "message");
|
||||
if (typeof message === "string" && message.trim()) return message;
|
||||
}
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas-specific stream hook — a direct copy of the main agent's useThreadStream,
|
||||
* configured for canvas_agent. Modify sendMessage here to inject canvas-specific
|
||||
* context (writing_artifact_path, canvas_mode, highlighted text, etc.).
|
||||
*/
|
||||
export function useCanvasThreadStream() {
|
||||
const navigate = useNavigate();
|
||||
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
|
||||
const { artifact, setArtifact, setIsStreaming, setStreamingArtifact } = useCanvasGraphContext();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const sendInFlightRef = useRef(false);
|
||||
const prevMsgCountRef = useRef(0);
|
||||
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState<string | null>(
|
||||
() => threadId,
|
||||
);
|
||||
const threadIdRef = useRef<string | null>(threadId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = threadId ?? null;
|
||||
setOnStreamThreadId(normalized);
|
||||
threadIdRef.current = normalized;
|
||||
if (!normalized) {
|
||||
sendInFlightRef.current = false;
|
||||
prevMsgCountRef.current = 0;
|
||||
setOptimisticMessages([]);
|
||||
}
|
||||
}, [threadId]);
|
||||
|
||||
const thread = useStream<CanvasThreadValues>({
|
||||
client: getCanvasAPIClient(),
|
||||
assistantId: CANVAS_AGENT_ID,
|
||||
threadId: onStreamThreadId,
|
||||
fetchStateHistory: { limit: 1 },
|
||||
onCreated(meta) {
|
||||
setOnStreamThreadId(meta.thread_id);
|
||||
threadIdRef.current = meta.thread_id;
|
||||
setThreadId(meta.thread_id);
|
||||
navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
|
||||
void getCanvasAPIClient()
|
||||
.threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
|
||||
.catch(() => {});
|
||||
},
|
||||
onUpdateEvent(data) {
|
||||
const updates: Array<Partial<CanvasThreadValues> | null> = Object.values(
|
||||
data || {},
|
||||
);
|
||||
for (const update of updates) {
|
||||
if (update?.artifact) {
|
||||
setArtifact(update.artifact as CanvasArtifact);
|
||||
}
|
||||
}
|
||||
},
|
||||
onCustomEvent(event: unknown) {
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
(event as { type: unknown }).type === "task_running"
|
||||
) {
|
||||
const e = event as {
|
||||
type: "task_running";
|
||||
task_id: string;
|
||||
message: AIMessage;
|
||||
};
|
||||
updateSubtask({ id: e.task_id, latestMessage: e.message });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"type" in event &&
|
||||
(event as { type: unknown }).type === "llm_retry" &&
|
||||
"message" in event &&
|
||||
typeof (event as { message: unknown }).message === "string"
|
||||
) {
|
||||
toast((event as { message: string }).message);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setOptimisticMessages([]);
|
||||
toast.error(getStreamErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
// Keep CanvasGraphContext.isStreaming in sync so the artifact panel and
|
||||
// quick-action bar can react to streaming state.
|
||||
useEffect(() => {
|
||||
setIsStreaming(thread.isLoading);
|
||||
}, [thread.isLoading, setIsStreaming]);
|
||||
|
||||
// Detect update_artifact tool calls from streaming messages to populate
|
||||
// streamingArtifact so the right panel shows content before the final
|
||||
// state update fires via onUpdateEvent.
|
||||
useEffect(() => {
|
||||
if (!thread.isLoading) {
|
||||
setStreamingArtifact(null);
|
||||
return;
|
||||
}
|
||||
const msgs = thread.messages;
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const msg = msgs[i];
|
||||
if (msg.type !== "ai") continue;
|
||||
const toolCalls = (msg as AIMessage).tool_calls;
|
||||
if (!toolCalls?.length) continue;
|
||||
const call = toolCalls.find((tc) => tc.name === "update_artifact");
|
||||
if (!call?.args) continue;
|
||||
const args = call.args as Record<string, unknown>;
|
||||
const partial: StreamingArtifactPartial = {
|
||||
type: args.type as "text" | "code" | undefined,
|
||||
title: typeof args.title === "string" ? args.title : undefined,
|
||||
content: typeof args.content === "string" ? args.content : undefined,
|
||||
language: typeof args.language === "string" ? args.language : undefined,
|
||||
};
|
||||
if (partial.title !== undefined || partial.content !== undefined) {
|
||||
setStreamingArtifact(partial);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [thread.messages, thread.isLoading, setStreamingArtifact]);
|
||||
|
||||
// Clear optimistic messages once the server stream begins delivering real ones.
|
||||
useEffect(() => {
|
||||
if (
|
||||
optimisticMessages.length > 0 &&
|
||||
thread.messages.length > prevMsgCountRef.current
|
||||
) {
|
||||
setOptimisticMessages([]);
|
||||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
|
||||
/**
|
||||
* Canvas writing-mode sendMessage — copied from ChatPage's handleSubmit flow.
|
||||
* Modify here to inject writing_artifact_path, canvas_mode, highlighted
|
||||
* text/code, or any other canvas-specific context the agent needs.
|
||||
*/
|
||||
const sendMessage = useCallback(
|
||||
async (text: string, extraContext?: Record<string, unknown>) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sendInFlightRef.current) return;
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
setOptimisticMessages([
|
||||
{
|
||||
type: "human",
|
||||
id: `opt-human-${Date.now()}`,
|
||||
content: [{ type: "text", text: trimmed }],
|
||||
additional_kwargs: {},
|
||||
},
|
||||
]);
|
||||
|
||||
// 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
|
||||
? {
|
||||
canvas_artifact_title: currentContent.title,
|
||||
canvas_artifact_type: currentContent.type,
|
||||
}
|
||||
: {};
|
||||
|
||||
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: messageText }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
threadId: threadIdRef.current ?? undefined,
|
||||
streamSubgraphs: true,
|
||||
streamResumable: true,
|
||||
config: {
|
||||
recursion_limit: 1000,
|
||||
...(modelName ? { configurable: { model_name: modelName } } : {}),
|
||||
},
|
||||
context: {
|
||||
...extraContext,
|
||||
...artifactContext,
|
||||
thread_id: threadIdRef.current ?? "",
|
||||
writing_mode: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
setOptimisticMessages([]);
|
||||
} finally {
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, modelName, artifact],
|
||||
);
|
||||
|
||||
const mergedThread =
|
||||
optimisticMessages.length > 0
|
||||
? ({
|
||||
...thread,
|
||||
messages: [...thread.messages, ...optimisticMessages],
|
||||
} as typeof thread)
|
||||
: thread;
|
||||
|
||||
return { thread: mergedThread, sendMessage, threadId };
|
||||
}
|
||||
@@ -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 || !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, modelName, editor, runStream],
|
||||
);
|
||||
|
||||
const rewriteFollowUp = useCallback(
|
||||
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
|
||||
if (!writingSelection || !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, 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 };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useRef } from 'react';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import type { GroupImperativeHandle } from 'react-resizable-panels';
|
||||
|
||||
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
|
||||
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
|
||||
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
|
||||
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
|
||||
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
|
||||
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable';
|
||||
|
||||
// Panel IDs used as keys in the v4 Layout map
|
||||
const WRITING_ID = 'aw-writing';
|
||||
const DRAFT_ID = 'aw-draft';
|
||||
|
||||
// Match the original canvas page proportions (chat=35, artifact=65)
|
||||
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
|
||||
|
||||
export default function AIWritingPage() {
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
return (
|
||||
<CanvasThreadProvider>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
<AIWritingPageHeader />
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
groupRef={layoutRef}
|
||||
defaultLayout={DEFAULT_LAYOUT}
|
||||
className="h-full"
|
||||
>
|
||||
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
|
||||
<ResizablePanel
|
||||
id={WRITING_ID}
|
||||
defaultSize={35}
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AIWritingPanel />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 右侧:草稿预览面板 */}
|
||||
<ResizablePanel
|
||||
id={DRAFT_ID}
|
||||
defaultSize={65}
|
||||
minSize={40}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<AIWritingDraftPanel />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, FileText, Pencil, Plus, Settings2, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { useArticleTypes } from '../hooks/useArticleTypes';
|
||||
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
|
||||
|
||||
// ── 样式常量 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const INPUT_CLASS =
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'focus-visible:ring-ring disabled:opacity-50 disabled:bg-muted';
|
||||
|
||||
const TEXTAREA_CLASS =
|
||||
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-xs shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'focus-visible:ring-ring resize-none';
|
||||
|
||||
// ── 表单状态类型 ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface FormState {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
prompt_hint: string;
|
||||
default_word_count: string;
|
||||
sort_order: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
key: '',
|
||||
label: '',
|
||||
description: '',
|
||||
prompt_hint: '',
|
||||
default_word_count: '800',
|
||||
sort_order: '0',
|
||||
};
|
||||
|
||||
// ── 主页面 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ArticleTypesConfigPage() {
|
||||
const navigate = useNavigate();
|
||||
const { articleTypes, loading, error, refresh, create, update, remove } = useArticleTypes();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// ── 对话框操作 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(t: ArticleType) {
|
||||
setEditingId(t.id);
|
||||
setForm({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
description: t.description ?? '',
|
||||
prompt_hint: t.prompt_hint ?? '',
|
||||
default_word_count: String(t.default_word_count),
|
||||
sort_order: String(t.sort_order),
|
||||
});
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const trimmedKey = form.key.trim();
|
||||
const trimmedLabel = form.label.trim();
|
||||
const wordCount = parseInt(form.default_word_count, 10);
|
||||
|
||||
if (!trimmedKey) { setFormError('标识不能为空'); return; }
|
||||
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
|
||||
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
|
||||
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editingId) {
|
||||
const req: UpdateArticleTypeRequest = {
|
||||
label: trimmedLabel,
|
||||
description: form.description.trim() || null,
|
||||
prompt_hint: form.prompt_hint.trim() || null,
|
||||
default_word_count: wordCount,
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
await update(editingId, req);
|
||||
} else {
|
||||
const req: CreateArticleTypeRequest = {
|
||||
key: trimmedKey,
|
||||
label: trimmedLabel,
|
||||
description: form.description.trim() || undefined,
|
||||
prompt_hint: form.prompt_hint.trim() || undefined,
|
||||
default_word_count: wordCount,
|
||||
sort_order: parseInt(form.sort_order, 10) || 0,
|
||||
};
|
||||
await create(req);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 删除操作 ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTargetId) return;
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await remove(deleteTargetId);
|
||||
setDeleteTargetId(null);
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden bg-background">
|
||||
{/* 页面头部 */}
|
||||
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => navigate(-1)}
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<Settings2 className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm font-semibold flex-1">文章类型配置</span>
|
||||
<Button size="sm" className="h-7 px-3 text-xs gap-1.5" onClick={openCreate}>
|
||||
<Plus className="size-3.5" />
|
||||
新增类型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading && (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">加载中…</p>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<p className="text-sm text-destructive text-center py-12">{error}</p>
|
||||
)}
|
||||
{!loading && !error && articleTypes.length === 0 && (
|
||||
<div className="text-center py-12 space-y-2">
|
||||
<Settings2 className="h-8 w-8 mx-auto text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">暂无文章类型</p>
|
||||
<p className="text-xs text-muted-foreground">点击右上角"新增类型"创建第一个文章类型</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && articleTypes.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{articleTypes.map((t) => (
|
||||
<ArticleTypeCard
|
||||
key={t.id}
|
||||
type={t}
|
||||
onEdit={() => openEdit(t)}
|
||||
onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新增 / 编辑对话框 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!saving) setDialogOpen(open); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? '编辑文章类型' : '新增文章类型'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
<FormField label="标识 (key)" required>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
placeholder="如 news、analysis(传给 AI 的唯一标识,仅限字母数字下划线)"
|
||||
value={form.key}
|
||||
onChange={(e) => setField('key', e.target.value)}
|
||||
disabled={!!editingId}
|
||||
/>
|
||||
{editingId && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">标识创建后不可修改</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField label="名称" required>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
placeholder="如 新闻资讯、深度特稿"
|
||||
value={form.label}
|
||||
onChange={(e) => setField('label', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="说明">
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
placeholder="简短描述,帮助用户了解该类型的适用场景"
|
||||
value={form.description}
|
||||
onChange={(e) => setField('description', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="写作风格提示词">
|
||||
<textarea
|
||||
className={TEXTAREA_CLASS}
|
||||
rows={3}
|
||||
placeholder="传入 AI 的写作风格说明,如:以新闻报道风格写作,注重 5W1H 要素,语言简洁客观…"
|
||||
value={form.prompt_hint}
|
||||
onChange={(e) => setField('prompt_hint', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField label="默认字数(字)" required>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={100}
|
||||
className={INPUT_CLASS}
|
||||
value={form.default_word_count}
|
||||
onChange={(e) => setField('default_word_count', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="排序权重">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="0"
|
||||
value={form.sort_order}
|
||||
onChange={(e) => setField('sort_order', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-xs text-destructive">{formError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog
|
||||
open={deleteTargetId !== null}
|
||||
onOpenChange={(open) => { if (!deleting && !open) setDeleteTargetId(null); }}
|
||||
>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
删除后不可恢复。已使用该类型创建的历史写作不受影响,但新写作将无法选择此类型。
|
||||
</p>
|
||||
{deleteError && (
|
||||
<p className="text-xs text-destructive">{deleteError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTargetId(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 文章类型卡片 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ArticleTypeCard({
|
||||
type,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
type: ArticleType;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2.5 hover:border-primary/30 transition-colors">
|
||||
{/* 标题行 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold">{type.label}</span>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border">
|
||||
{type.key}
|
||||
</span>
|
||||
</div>
|
||||
{type.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={onEdit}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive/70 hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 写作提示词预览 */}
|
||||
{type.prompt_hint && (
|
||||
<div className="rounded-md bg-muted/50 px-2.5 py-2">
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{type.prompt_hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部元信息 */}
|
||||
<div className="flex items-center gap-3 pt-0.5 border-t">
|
||||
<span className="text-[11px] text-muted-foreground flex items-center gap-1 pt-2">
|
||||
<FileText className="h-3 w-3" />
|
||||
默认 {type.default_word_count} 字
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground pt-2">
|
||||
排序 {type.sort_order}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 表单字段包装 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
|
||||
import { FileText, Code2, Sparkles, Loader2, PenLine } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createCanvasThread } from "../api/threads";
|
||||
@@ -86,6 +86,23 @@ export default function CanvasHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AI 智能写作入口 */}
|
||||
<button
|
||||
onClick={() => navigate('ai-writing')}
|
||||
className="group w-full flex items-center gap-4 rounded-xl border border-orange-200 dark:border-orange-900/50 bg-orange-50/50 dark:bg-orange-950/20 p-5 text-left transition-all hover:border-orange-400/60 hover:shadow-md hover:bg-orange-50 dark:hover:bg-orange-950/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-400"
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/40 shrink-0 group-hover:bg-orange-200/80 dark:group-hover:bg-orange-800/40 transition-colors">
|
||||
<PenLine className="w-5 h-5 text-orange-500 dark:text-orange-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm text-orange-700 dark:text-orange-300">AI 智能写作</p>
|
||||
<p className="text-xs text-orange-600/70 dark:text-orange-400/60 leading-relaxed mt-0.5">
|
||||
多智能体协作——搜集素材、规划大纲、起草正文、编辑审核,全流程自动完成
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-orange-400/60 shrink-0 text-xs">→</span>
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -2,11 +2,15 @@ import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import CanvasHomePage from "../pages/CanvasHomePage";
|
||||
import CanvasThreadPage from "../pages/CanvasThreadPage";
|
||||
import AIWritingPage from "../pages/AIWritingPage";
|
||||
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
|
||||
|
||||
export default function OpenCanvasRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<CanvasHomePage />} />
|
||||
<Route path="ai-writing" element={<AIWritingPage />} />
|
||||
<Route path="article-types" element={<ArticleTypesConfigPage />} />
|
||||
<Route path=":thread_id" element={<CanvasThreadPage />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// ── 素材 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIMaterial {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
relevanceScore: number;
|
||||
sourceType: 'internet' | 'intranet';
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
}
|
||||
|
||||
// ── 大纲 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OutlineSection {
|
||||
sectionTitle: string;
|
||||
keyPoints: string[];
|
||||
materialIds: string[];
|
||||
}
|
||||
|
||||
export interface AIOutline {
|
||||
title: string;
|
||||
sections: OutlineSection[];
|
||||
}
|
||||
|
||||
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReviewIssue {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
severity: 'major' | 'minor';
|
||||
location: string;
|
||||
issue: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
verdict: 'pass' | 'reject';
|
||||
factScore: number;
|
||||
logicScore: number;
|
||||
languageScore: number;
|
||||
overallScore: number;
|
||||
passThreshold: number;
|
||||
revisionNotes: ReviewIssue[];
|
||||
}
|
||||
|
||||
// ── 写作状态 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WritingStatus =
|
||||
| 'idle'
|
||||
| 'researching'
|
||||
| 'awaiting_material_confirm'
|
||||
| 'writing_outline'
|
||||
| 'awaiting_outline_confirm'
|
||||
| 'writing_draft'
|
||||
| 'awaiting_draft_confirm'
|
||||
| 'reviewing'
|
||||
| 'awaiting_review_confirm'
|
||||
| 'revising'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProgressEventType =
|
||||
| 'connected'
|
||||
| 'step_start'
|
||||
| 'step_done'
|
||||
| 'research_progress'
|
||||
| 'materials_ready'
|
||||
| 'outline_chunk'
|
||||
| 'outline_ready'
|
||||
| 'draft_chunk'
|
||||
| 'draft_ready'
|
||||
| 'review_chunk'
|
||||
| 'review_item_done'
|
||||
| 'review_ready'
|
||||
| 'await_user'
|
||||
| 'resumed'
|
||||
| 'notice'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
export interface ProgressEvent {
|
||||
type: ProgressEventType;
|
||||
sessionId: string;
|
||||
agentName?: string;
|
||||
message: string;
|
||||
// notice(非致命告警)/ error:标题 + 真实原因
|
||||
title?: string;
|
||||
detail?: string;
|
||||
// materials_ready
|
||||
materials?: AIMaterial[];
|
||||
keywords?: string[];
|
||||
materialSummary?: string;
|
||||
// outline_ready
|
||||
outline?: AIOutline;
|
||||
// draft_ready / draft_chunk
|
||||
draftMarkdown?: string;
|
||||
draftTitle?: string;
|
||||
revisionCount?: number;
|
||||
// review_item_done (streaming per-category result)
|
||||
reviewItemCategory?: 'fact' | 'logic' | 'language';
|
||||
reviewItemScore?: number;
|
||||
reviewItemIssues?: ReviewIssue[];
|
||||
// review_ready
|
||||
reviewResult?: ReviewResult;
|
||||
// await_user
|
||||
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
|
||||
pauseContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserInterventionPayload {
|
||||
action: string;
|
||||
approvedMaterialIds?: string[];
|
||||
extraKeywords?: string[];
|
||||
editedOutline?: AIOutline;
|
||||
outlineFeedback?: string;
|
||||
userRevisionNotes?: string;
|
||||
overrideVerdict?: boolean;
|
||||
// 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选
|
||||
selectedIssueIndices?: number[];
|
||||
}
|
||||
|
||||
// ── 写作请求 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIWritingRequest {
|
||||
userIntent: string;
|
||||
articleType: string;
|
||||
targetAudience: string;
|
||||
wordCountTarget: number;
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamingReviewItem {
|
||||
category: 'fact' | 'logic' | 'language';
|
||||
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
|
||||
isDone: boolean;
|
||||
// 流式过程文本(正文 / 思考分开)
|
||||
streamingText: string;
|
||||
thinkingText: string;
|
||||
// 完成后的结构化结果
|
||||
score: number;
|
||||
issues: ReviewIssue[];
|
||||
}
|
||||
|
||||
// ── 修改摘要 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RevisionSummary {
|
||||
revisedIssues: ReviewIssue[];
|
||||
skippedIssues: ReviewIssue[];
|
||||
oldWordCount: number;
|
||||
newWordCount: number;
|
||||
}
|
||||
|
||||
// ── 流式草稿章节 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreamingDraftSection {
|
||||
sectionIndex: number;
|
||||
sectionTitle: string;
|
||||
text: string; // 正文内容(非思考)
|
||||
thinkingText: string; // 思考内容(<think>...</think> 内)
|
||||
isDone: boolean;
|
||||
}
|
||||
|
||||
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompletedIntervention {
|
||||
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
|
||||
action: string;
|
||||
// material_confirm
|
||||
materials?: AIMaterial[];
|
||||
// outline_confirm
|
||||
outline?: AIOutline;
|
||||
// draft_confirm
|
||||
draftTitle?: string;
|
||||
draftWordCount?: number;
|
||||
reviewResult?: ReviewResult;
|
||||
// review_confirm
|
||||
}
|
||||
|
||||
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIWritingState {
|
||||
sessionId: string | null;
|
||||
status: WritingStatus;
|
||||
isPaused: boolean;
|
||||
progressEvents: ProgressEvent[];
|
||||
materials: AIMaterial[];
|
||||
outline: AIOutline | null;
|
||||
currentDraftMarkdown: string;
|
||||
currentDraftTitle: string;
|
||||
reviewResult: ReviewResult | null;
|
||||
revisionCount: number;
|
||||
currentPause: ProgressEvent | null;
|
||||
completedInterventions: CompletedIntervention[];
|
||||
error: string | null;
|
||||
// 流式草稿
|
||||
streamingDraftTitle: string;
|
||||
streamingDraftSections: StreamingDraftSection[];
|
||||
// 流式大纲(正文 / 思考分开)
|
||||
outlineStreamingText: string;
|
||||
outlineThinkingText: string;
|
||||
// 素材收集实时进度(最新一条提示文案)
|
||||
researchProgress: string;
|
||||
// 流式审核(逐条到达)
|
||||
streamingReviewItems: StreamingReviewItem[];
|
||||
// 修改摘要
|
||||
revisionSummary: RevisionSummary | null;
|
||||
// 待修改上下文(用于生成摘要)
|
||||
pendingRevisionContext: {
|
||||
submittedIssues: ReviewIssue[];
|
||||
skippedIssues: ReviewIssue[];
|
||||
oldWordCount: number;
|
||||
} | null;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 中文语境下的「字数」统计,与后端 `nodes/_utils.py:count_words` 保持同一口径:
|
||||
* 汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。
|
||||
* 不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。
|
||||
*/
|
||||
export function countWords(text: string): number {
|
||||
if (!text) return 0;
|
||||
const cjk = (text.match(/[一-鿿]/g) ?? []).length;
|
||||
const cjkPunct = (text.match(/[,。、;:?!“”‘’()《》【】「」『』〈〉—…·~]/g) ?? []).length;
|
||||
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
|
||||
return cjk + cjkPunct + alnumRuns;
|
||||
}
|
||||
@@ -566,6 +566,186 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 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);
|
||||
text-indent: 2em; /* 段落首行缩进 */
|
||||
}
|
||||
|
||||
.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;
|
||||
text-indent: 0; /* 列表项内段落不缩进 */
|
||||
}
|
||||
|
||||
.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 {
|
||||
text-indent: 0; /* 占位文案不缩进 */
|
||||
}
|
||||
|
||||
.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