feature:新增章节内容-局部重写功能,支持扩展内容
This commit is contained in:
+335
-1
@@ -32,7 +32,8 @@ from app.schemas.chapter import (
|
||||
BatchGenerateRequest,
|
||||
BatchGenerateResponse,
|
||||
BatchGenerateStatusResponse,
|
||||
ExpansionPlanUpdate
|
||||
ExpansionPlanUpdate,
|
||||
PartialRegenerateRequest
|
||||
)
|
||||
from app.schemas.regeneration import (
|
||||
ChapterRegenerateRequest,
|
||||
@@ -3411,3 +3412,336 @@ async def update_chapter_expansion_plan(
|
||||
"message": "规划信息更新成功"
|
||||
}
|
||||
|
||||
|
||||
# ==================== 局部重写相关API ====================
|
||||
|
||||
@router.post("/{chapter_id}/partial-regenerate-stream", summary="流式局部重写选中内容")
|
||||
async def partial_regenerate_stream(
|
||||
chapter_id: str,
|
||||
request: Request,
|
||||
partial_request: PartialRegenerateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
):
|
||||
"""
|
||||
对章节中选中的部分内容进行流式重写
|
||||
|
||||
工作流程:
|
||||
1. 验证章节和选中内容的有效性
|
||||
2. 截取上下文(前后文)
|
||||
3. 根据用户要求构建提示词
|
||||
4. 流式生成重写内容
|
||||
5. 返回重写结果(不自动保存,由前端决定是否应用)
|
||||
"""
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
# 验证章节存在
|
||||
chapter_result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = chapter_result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
if not chapter.content or chapter.content.strip() == "":
|
||||
raise HTTPException(status_code=400, detail="章节内容为空")
|
||||
|
||||
# 验证用户权限
|
||||
await verify_project_access(chapter.project_id, user_id, db)
|
||||
|
||||
# 验证位置参数
|
||||
content_length = len(chapter.content)
|
||||
if partial_request.start_position >= content_length:
|
||||
raise HTTPException(status_code=400, detail="起始位置超出内容范围")
|
||||
if partial_request.end_position > content_length:
|
||||
raise HTTPException(status_code=400, detail="结束位置超出内容范围")
|
||||
if partial_request.start_position >= partial_request.end_position:
|
||||
raise HTTPException(status_code=400, detail="起始位置必须小于结束位置")
|
||||
|
||||
# 验证选中的文本是否匹配
|
||||
actual_selected = chapter.content[partial_request.start_position:partial_request.end_position]
|
||||
if actual_selected != partial_request.selected_text:
|
||||
# 位置可能有偏差,尝试在附近查找
|
||||
search_start = max(0, partial_request.start_position - 50)
|
||||
search_end = min(content_length, partial_request.end_position + 50)
|
||||
search_area = chapter.content[search_start:search_end]
|
||||
|
||||
if partial_request.selected_text in search_area:
|
||||
# 找到了,更新位置
|
||||
offset = search_area.find(partial_request.selected_text)
|
||||
partial_request.start_position = search_start + offset
|
||||
partial_request.end_position = partial_request.start_position + len(partial_request.selected_text)
|
||||
logger.info(f"⚠️ 选中文本位置校正: {partial_request.start_position}-{partial_request.end_position}")
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="选中的文本与章节内容不匹配,请刷新页面后重试"
|
||||
)
|
||||
|
||||
# 预先获取项目信息和写作风格
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == chapter.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
|
||||
# 获取写作风格
|
||||
style_content = ""
|
||||
style_id = partial_request.style_id
|
||||
|
||||
# 如果没有指定风格,尝试使用项目的默认风格
|
||||
if not style_id:
|
||||
from app.models.project_default_style import ProjectDefaultStyle
|
||||
default_style_result = await db.execute(
|
||||
select(ProjectDefaultStyle.style_id)
|
||||
.where(ProjectDefaultStyle.project_id == chapter.project_id)
|
||||
)
|
||||
default_style_id = default_style_result.scalar_one_or_none()
|
||||
if default_style_id:
|
||||
style_id = default_style_id
|
||||
logger.info(f"📝 局部重写 - 使用项目默认写作风格: {style_id}")
|
||||
|
||||
# 获取风格内容
|
||||
if style_id:
|
||||
style_result = await db.execute(
|
||||
select(WritingStyle).where(WritingStyle.id == style_id)
|
||||
)
|
||||
style = style_result.scalar_one_or_none()
|
||||
if style:
|
||||
if style.user_id is None or style.user_id == user_id:
|
||||
style_content = style.prompt_content or ""
|
||||
style_type = "全局预设" if style.user_id is None else "用户自定义"
|
||||
logger.info(f"✅ 局部重写 - 使用写作风格: {style.name} ({style_type})")
|
||||
else:
|
||||
logger.warning(f"⚠️ 风格 {style_id} 不属于当前用户,跳过")
|
||||
|
||||
async def event_generator():
|
||||
"""流式生成事件生成器"""
|
||||
from app.utils.sse_response import WizardProgressTracker
|
||||
tracker = WizardProgressTracker("局部重写")
|
||||
|
||||
try:
|
||||
yield await tracker.start()
|
||||
yield await tracker.loading("准备重写上下文...", 0.3)
|
||||
|
||||
# 截取上下文
|
||||
context_chars = partial_request.context_chars
|
||||
start_pos = partial_request.start_position
|
||||
end_pos = partial_request.end_position
|
||||
|
||||
# 前文:从start_pos往前截取context_chars个字符
|
||||
context_before_start = max(0, start_pos - context_chars)
|
||||
context_before = chapter.content[context_before_start:start_pos]
|
||||
|
||||
# 后文:从end_pos往后截取context_chars个字符
|
||||
context_after_end = min(content_length, end_pos + context_chars)
|
||||
context_after = chapter.content[end_pos:context_after_end]
|
||||
|
||||
# 原文
|
||||
original_text = partial_request.selected_text
|
||||
original_word_count = len(original_text)
|
||||
|
||||
logger.info(f"📝 局部重写 - 原文: {original_word_count}字, 前文: {len(context_before)}字, 后文: {len(context_after)}字")
|
||||
|
||||
yield await tracker.loading("构建提示词...", 0.5)
|
||||
|
||||
# 构建字数要求
|
||||
length_requirement = ""
|
||||
if partial_request.length_mode == "similar":
|
||||
min_words = int(original_word_count * 0.8)
|
||||
max_words = int(original_word_count * 1.2)
|
||||
length_requirement = f"保持与原文相近的字数(约{original_word_count}字,允许{min_words}-{max_words}字浮动)"
|
||||
elif partial_request.length_mode == "expand":
|
||||
min_words = int(original_word_count * 1.2)
|
||||
max_words = int(original_word_count * 2.0)
|
||||
length_requirement = f"适当扩展内容(目标{min_words}-{max_words}字)"
|
||||
elif partial_request.length_mode == "condense":
|
||||
min_words = int(original_word_count * 0.5)
|
||||
max_words = int(original_word_count * 0.8)
|
||||
length_requirement = f"精简压缩内容(目标{min_words}-{max_words}字)"
|
||||
elif partial_request.length_mode == "custom" and partial_request.target_word_count:
|
||||
length_requirement = f"目标字数:约{partial_request.target_word_count}字(允许±20%浮动)"
|
||||
else:
|
||||
length_requirement = f"保持与原文相近的字数(约{original_word_count}字)"
|
||||
|
||||
# 获取提示词模板
|
||||
template = await PromptService.get_template("PARTIAL_REGENERATE", user_id, db)
|
||||
if not template:
|
||||
template = PromptService.PARTIAL_REGENERATE
|
||||
|
||||
# 构建提示词
|
||||
prompt = PromptService.format_prompt(
|
||||
template,
|
||||
context_before=context_before if context_before else "(这是章节开头)",
|
||||
original_word_count=original_word_count,
|
||||
selected_text=original_text,
|
||||
context_after=context_after if context_after else "(这是章节结尾)",
|
||||
user_instructions=partial_request.user_instructions,
|
||||
length_requirement=length_requirement,
|
||||
style_content=style_content if style_content else "保持与原文一致的叙事风格"
|
||||
)
|
||||
|
||||
yield await tracker.preparing("开始生成...")
|
||||
|
||||
# 计算 max_tokens
|
||||
if partial_request.length_mode == "expand":
|
||||
target_words = int(original_word_count * 2.0)
|
||||
elif partial_request.length_mode == "custom" and partial_request.target_word_count:
|
||||
target_words = partial_request.target_word_count
|
||||
else:
|
||||
target_words = int(original_word_count * 1.5)
|
||||
|
||||
calculated_max_tokens = max(500, min(int(target_words * 3), 8000))
|
||||
|
||||
# 流式生成
|
||||
full_content = ""
|
||||
chunk_count = 0
|
||||
|
||||
yield await tracker.generating(
|
||||
current_chars=0,
|
||||
estimated_total=target_words
|
||||
)
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
prompt=prompt,
|
||||
max_tokens=calculated_max_tokens
|
||||
):
|
||||
full_content += chunk
|
||||
chunk_count += 1
|
||||
|
||||
# 发送内容块
|
||||
yield await tracker.generating_chunk(chunk)
|
||||
|
||||
# 每5个chunk发送一次进度更新
|
||||
if chunk_count % 5 == 0:
|
||||
yield await tracker.generating(
|
||||
current_chars=len(full_content),
|
||||
estimated_total=target_words,
|
||||
message=f'正在重写中... 已生成 {len(full_content)} 字'
|
||||
)
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# 清理输出(移除可能的前后缀)
|
||||
full_content = full_content.strip()
|
||||
|
||||
# 移除常见的AI输出前缀
|
||||
prefixes_to_remove = [
|
||||
"重写后:", "重写后:", "改写后:", "改写后:",
|
||||
"以下是重写后的内容:", "以下是重写后的内容:",
|
||||
"重写内容:", "重写内容:"
|
||||
]
|
||||
for prefix in prefixes_to_remove:
|
||||
if full_content.startswith(prefix):
|
||||
full_content = full_content[len(prefix):].strip()
|
||||
break
|
||||
|
||||
# 移除首尾可能的引号
|
||||
if (full_content.startswith('"') and full_content.endswith('"')) or \
|
||||
(full_content.startswith("'") and full_content.endswith("'")):
|
||||
full_content = full_content[1:-1]
|
||||
if (full_content.startswith('「') and full_content.endswith('」')) or \
|
||||
(full_content.startswith('『') and full_content.endswith('』')):
|
||||
full_content = full_content[1:-1]
|
||||
|
||||
new_word_count = len(full_content)
|
||||
|
||||
logger.info(f"✅ 局部重写完成: 原文{original_word_count}字 -> 新文{new_word_count}字")
|
||||
|
||||
# 完成
|
||||
yield await tracker.complete("重写完成!")
|
||||
|
||||
# 发送结果数据
|
||||
yield await tracker.result({
|
||||
'new_text': full_content,
|
||||
'word_count': new_word_count,
|
||||
'original_word_count': original_word_count,
|
||||
'start_position': partial_request.start_position,
|
||||
'end_position': partial_request.end_position
|
||||
})
|
||||
|
||||
yield await tracker.done()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 局部重写失败: {str(e)}", exc_info=True)
|
||||
yield await tracker.error(str(e))
|
||||
|
||||
return create_sse_response(event_generator())
|
||||
|
||||
|
||||
@router.post("/{chapter_id}/apply-partial-regenerate", summary="应用局部重写结果")
|
||||
async def apply_partial_regenerate(
|
||||
chapter_id: str,
|
||||
request: Request,
|
||||
apply_request: dict,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
将局部重写的结果应用到章节内容中
|
||||
|
||||
请求体:
|
||||
- new_text: 重写后的新内容
|
||||
- start_position: 原文起始位置
|
||||
- end_position: 原文结束位置
|
||||
"""
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
# 验证章节存在
|
||||
chapter_result = await db.execute(
|
||||
select(Chapter).where(Chapter.id == chapter_id)
|
||||
)
|
||||
chapter = chapter_result.scalar_one_or_none()
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
|
||||
# 验证用户权限
|
||||
await verify_project_access(chapter.project_id, user_id, db)
|
||||
|
||||
# 获取参数
|
||||
new_text = apply_request.get('new_text', '')
|
||||
start_position = apply_request.get('start_position', 0)
|
||||
end_position = apply_request.get('end_position', 0)
|
||||
|
||||
if not new_text:
|
||||
raise HTTPException(status_code=400, detail="新内容不能为空")
|
||||
|
||||
# 验证位置有效性
|
||||
content_length = len(chapter.content)
|
||||
if start_position < 0 or end_position > content_length or start_position >= end_position:
|
||||
raise HTTPException(status_code=400, detail="位置参数无效")
|
||||
|
||||
# 构建新内容
|
||||
old_word_count = chapter.word_count or 0
|
||||
new_content = chapter.content[:start_position] + new_text + chapter.content[end_position:]
|
||||
new_word_count = len(new_content)
|
||||
|
||||
# 更新章节
|
||||
chapter.content = new_content
|
||||
chapter.word_count = new_word_count
|
||||
|
||||
# 更新项目字数
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == chapter.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if project:
|
||||
project.current_words = project.current_words - old_word_count + new_word_count
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(chapter)
|
||||
|
||||
logger.info(f"✅ 局部重写已应用: 章节{chapter_id}, {old_word_count}字 -> {new_word_count}字")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"chapter_id": chapter_id,
|
||||
"word_count": new_word_count,
|
||||
"old_word_count": old_word_count,
|
||||
"message": "局部重写已应用"
|
||||
}
|
||||
|
||||
|
||||
@@ -164,4 +164,51 @@ class ExpansionPlanResponse(BaseModel):
|
||||
"""章节规划响应模型"""
|
||||
id: str = Field(..., description="章节ID")
|
||||
expansion_plan: Optional[Dict[str, Any]] = Field(None, description="规划数据")
|
||||
message: str = Field(..., description="响应消息")
|
||||
message: str = Field(..., description="响应消息")
|
||||
|
||||
|
||||
class PartialRegenerateRequest(BaseModel):
|
||||
"""局部重写请求参数"""
|
||||
selected_text: str = Field(..., description="选中的原文内容")
|
||||
start_position: int = Field(..., description="在章节内容中的起始位置(字符索引)", ge=0)
|
||||
end_position: int = Field(..., description="在章节内容中的结束位置(字符索引)", ge=0)
|
||||
user_instructions: str = Field(..., description="用户的修改要求", min_length=1, max_length=1000)
|
||||
|
||||
# 可选参数
|
||||
context_chars: int = Field(
|
||||
500,
|
||||
description="上下文截取长度(前后各截取多少字符)",
|
||||
ge=100,
|
||||
le=2000
|
||||
)
|
||||
style_id: Optional[int] = Field(None, description="写作风格ID,不提供则使用项目默认风格")
|
||||
length_mode: Optional[str] = Field(
|
||||
"similar",
|
||||
description="字数调整模式:similar(保持相近)/expand(适当扩展)/condense(精简压缩)/custom(自定义)"
|
||||
)
|
||||
target_word_count: Optional[int] = Field(
|
||||
None,
|
||||
description="指定目标字数(仅当length_mode为custom时有效)",
|
||||
ge=10,
|
||||
le=5000
|
||||
)
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"selected_text": "林霄挥剑斩向敌人,剑光凌厉,一招制敌。",
|
||||
"start_position": 1234,
|
||||
"end_position": 1260,
|
||||
"user_instructions": "增加更细腻的打斗描写,加入主角的心理活动",
|
||||
"context_chars": 500,
|
||||
"length_mode": "expand"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class PartialRegenerateResponse(BaseModel):
|
||||
"""局部重写响应模型"""
|
||||
success: bool = Field(..., description="是否成功")
|
||||
new_text: str = Field(..., description="重写后的新内容")
|
||||
word_count: int = Field(..., description="新内容字数")
|
||||
original_word_count: int = Field(..., description="原文字数")
|
||||
message: str = Field("重写成功", description="响应消息")
|
||||
@@ -2083,6 +2083,72 @@ class PromptService:
|
||||
❌ 所有职业使用相同的阶段数
|
||||
❌ 输出markdown标记
|
||||
❌ 职业设计与世界观脱节
|
||||
</constraints>"""
|
||||
|
||||
# 局部重写提示词(RTCO框架)
|
||||
PARTIAL_REGENERATE = """<system>
|
||||
你是一位专业的小说改写助手,擅长根据用户的修改要求精准改写指定段落,同时确保与前后文无缝衔接。
|
||||
</system>
|
||||
|
||||
<task>
|
||||
【改写任务】
|
||||
根据用户的修改要求,重写下面选中的文本段落。
|
||||
|
||||
【重要要求】
|
||||
1. 只输出重写后的内容,不要包含任何解释、前缀或后缀
|
||||
2. 保持与前后文的自然衔接和语气连贯
|
||||
3. 严格遵循用户的修改要求
|
||||
4. 保持整体叙事风格的一致性
|
||||
</task>
|
||||
|
||||
<context priority="P0">
|
||||
【前文参考】(用于衔接,勿重复)
|
||||
{context_before}
|
||||
|
||||
【需要重写的原文】(共{original_word_count}字)
|
||||
{selected_text}
|
||||
|
||||
【后文参考】(用于衔接,勿重复)
|
||||
{context_after}
|
||||
</context>
|
||||
|
||||
<user_requirements priority="P0">
|
||||
【用户修改要求】
|
||||
{user_instructions}
|
||||
|
||||
【字数要求】
|
||||
{length_requirement}
|
||||
</user_requirements>
|
||||
|
||||
<style priority="P1">
|
||||
【写作风格】
|
||||
{style_content}
|
||||
</style>
|
||||
|
||||
<output>
|
||||
【输出规范】
|
||||
直接输出重写后的内容,从故事内容开始写。
|
||||
- 不要输出任何解释或说明文字
|
||||
- 不要输出"重写后:"等前缀
|
||||
- 不要输出引号包裹内容
|
||||
- 确保输出内容可以直接替换原文
|
||||
|
||||
请直接输出重写后的内容:
|
||||
</output>
|
||||
|
||||
<constraints>
|
||||
【必须遵守】
|
||||
✅ 前后衔接:输出内容必须与前文自然衔接,与后文平滑过渡
|
||||
✅ 风格一致:保持与原文相同的叙事风格、语气和人称
|
||||
✅ 要求优先:严格执行用户的修改要求
|
||||
✅ 字数控制:遵循字数要求
|
||||
|
||||
【禁止事项】
|
||||
❌ 重复前文内容
|
||||
❌ 重复后文内容
|
||||
❌ 添加任何元信息或说明
|
||||
❌ 改变叙事人称或视角
|
||||
❌ 偏离用户的修改要求
|
||||
</constraints>"""
|
||||
|
||||
@staticmethod
|
||||
@@ -2409,9 +2475,16 @@ class PromptService:
|
||||
"name": "章节重写系统提示",
|
||||
"category": "章节重写",
|
||||
"description": "用于章节重写的系统提示词",
|
||||
"parameters": ["chapter_number", "title", "word_count", "content", "modification_instructions",
|
||||
"parameters": ["chapter_number", "title", "word_count", "content", "modification_instructions",
|
||||
"project_context", "style_content", "target_word_count"]
|
||||
},
|
||||
"PARTIAL_REGENERATE": {
|
||||
"name": "局部重写",
|
||||
"category": "章节重写",
|
||||
"description": "根据用户修改要求重写选中的段落内容",
|
||||
"parameters": ["context_before", "original_word_count", "selected_text", "context_after",
|
||||
"user_instructions", "length_requirement", "style_content"]
|
||||
},
|
||||
"PLOT_ANALYSIS": {
|
||||
"name": "情节分析",
|
||||
"category": "情节分析",
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Modal, Input, Button, Space, Radio, InputNumber, Card, message, Alert, Spin, Typography, Divider } from 'antd';
|
||||
import { ThunderboltOutlined, CheckOutlined, ReloadOutlined, EditOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { chapterApi } from '../services/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface PartialRegenerateModalProps {
|
||||
visible: boolean;
|
||||
chapterId: string;
|
||||
selectedText: string;
|
||||
startPosition: number;
|
||||
endPosition: number;
|
||||
styleId?: number;
|
||||
onClose: () => void;
|
||||
onApply: (newText: string, startPosition: number, endPosition: number) => void;
|
||||
}
|
||||
|
||||
type LengthMode = 'similar' | 'expand' | 'condense' | 'custom';
|
||||
|
||||
/**
|
||||
* 局部重写弹窗组件
|
||||
* 用于配置和执行选中文本的AI重写
|
||||
*/
|
||||
export const PartialRegenerateModal: React.FC<PartialRegenerateModalProps> = ({
|
||||
visible,
|
||||
chapterId,
|
||||
selectedText,
|
||||
startPosition,
|
||||
endPosition,
|
||||
styleId,
|
||||
onClose,
|
||||
onApply,
|
||||
}) => {
|
||||
const [userInstructions, setUserInstructions] = useState('');
|
||||
const [lengthMode, setLengthMode] = useState<LengthMode>('similar');
|
||||
const [customWordCount, setCustomWordCount] = useState<number>(selectedText.length);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generatedText, setGeneratedText] = useState('');
|
||||
const [hasGenerated, setHasGenerated] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const generatedTextRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setUserInstructions('');
|
||||
setLengthMode('similar');
|
||||
setCustomWordCount(selectedText.length);
|
||||
setIsGenerating(false);
|
||||
setGeneratedText('');
|
||||
setHasGenerated(false);
|
||||
setProgress(0);
|
||||
setProgressMessage('');
|
||||
}
|
||||
}, [visible, selectedText.length]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (generatedTextRef.current && isGenerating) {
|
||||
generatedTextRef.current.scrollTop = generatedTextRef.current.scrollHeight;
|
||||
}
|
||||
}, [generatedText, isGenerating]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!userInstructions.trim()) {
|
||||
message.warning('请输入重写要求');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setGeneratedText('');
|
||||
setProgress(0);
|
||||
setProgressMessage('准备生成...');
|
||||
|
||||
// 创建 AbortController 用于取消请求
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
await chapterApi.partialRegenerateStream(
|
||||
chapterId,
|
||||
{
|
||||
selected_text: selectedText,
|
||||
start_position: startPosition,
|
||||
end_position: endPosition,
|
||||
user_instructions: userInstructions,
|
||||
context_chars: 500,
|
||||
style_id: styleId,
|
||||
length_mode: lengthMode,
|
||||
target_word_count: lengthMode === 'custom' ? customWordCount : undefined,
|
||||
},
|
||||
{
|
||||
onProgress: (msg, prog) => {
|
||||
setProgress(prog);
|
||||
setProgressMessage(msg);
|
||||
},
|
||||
onChunk: (content) => {
|
||||
setGeneratedText(prev => prev + content);
|
||||
},
|
||||
onResult: () => {
|
||||
setProgress(100);
|
||||
setProgressMessage('生成完成');
|
||||
setHasGenerated(true);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('SSE错误:', error);
|
||||
message.error(error || '生成过程中发生错误');
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsGenerating(false);
|
||||
setHasGenerated(true);
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('生成失败:', error);
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
message.error('生成失败,请重试');
|
||||
}
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isGenerating && abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
setIsGenerating(false);
|
||||
message.info('已取消生成');
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
if (!generatedText.trim()) {
|
||||
message.warning('没有可应用的内容');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端应用更改
|
||||
await chapterApi.applyPartialRegenerate(chapterId, {
|
||||
new_text: generatedText,
|
||||
start_position: startPosition,
|
||||
end_position: endPosition,
|
||||
});
|
||||
|
||||
message.success('已应用重写内容');
|
||||
onApply(generatedText, startPosition, endPosition);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('应用失败:', error);
|
||||
message.error('应用失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
setGeneratedText('');
|
||||
setHasGenerated(false);
|
||||
setProgress(0);
|
||||
setProgressMessage('');
|
||||
handleGenerate();
|
||||
};
|
||||
|
||||
const getLengthModeDescription = (mode: LengthMode): string => {
|
||||
const descriptions: Record<LengthMode, string> = {
|
||||
similar: '保持与原文相近的长度',
|
||||
expand: '扩展内容,增加更多细节',
|
||||
condense: '精简内容,保留核心要点',
|
||||
custom: '指定目标字数',
|
||||
};
|
||||
return descriptions[mode];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<EditOutlined style={{ color: 'var(--color-primary)' }} />
|
||||
<span>AI局部重写</span>
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
width={800}
|
||||
centered
|
||||
maskClosable={!isGenerating}
|
||||
closable={!isGenerating}
|
||||
keyboard={!isGenerating}
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={handleCancel} disabled={isGenerating}>
|
||||
取消
|
||||
</Button>
|
||||
{!hasGenerated ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={isGenerating ? <LoadingOutlined /> : <ThunderboltOutlined />}
|
||||
onClick={handleGenerate}
|
||||
loading={isGenerating}
|
||||
disabled={!userInstructions.trim()}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 12px rgba(77, 128, 136, 0.3)',
|
||||
}}
|
||||
>
|
||||
{isGenerating ? '生成中...' : '开始重写'}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRegenerate}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleAccept}
|
||||
style={{ background: '#52c41a', borderColor: '#52c41a' }}
|
||||
>
|
||||
接受并应用
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 原文展示 */}
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>原文内容</Text>
|
||||
<Text type="secondary">({selectedText.length}字)</Text>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 150,
|
||||
overflowY: 'auto',
|
||||
background: '#fafafa',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Paragraph
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
color: '#595959',
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{selectedText}
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
{/* 重写要求输入 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
重写要求 <Text type="danger">*</Text>
|
||||
</Text>
|
||||
<TextArea
|
||||
value={userInstructions}
|
||||
onChange={(e) => setUserInstructions(e.target.value)}
|
||||
placeholder="请描述您希望如何重写这段内容,例如: - 让描写更加生动细腻 - 增加环境氛围描写 - 加强角色心理活动 - 改变叙事节奏,更加紧凑 - 添加对话内容"
|
||||
rows={4}
|
||||
disabled={isGenerating}
|
||||
style={{ resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 长度模式选择 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
长度控制
|
||||
</Text>
|
||||
<Radio.Group
|
||||
value={lengthMode}
|
||||
onChange={(e) => setLengthMode(e.target.value)}
|
||||
disabled={isGenerating}
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="similar">保持长度</Radio.Button>
|
||||
<Radio.Button value="expand">扩展内容</Radio.Button>
|
||||
<Radio.Button value="condense">精简内容</Radio.Button>
|
||||
<Radio.Button value="custom">自定义</Radio.Button>
|
||||
</Radio.Group>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{getLengthModeDescription(lengthMode)}
|
||||
</Text>
|
||||
</div>
|
||||
{lengthMode === 'custom' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space>
|
||||
<Text>目标字数:</Text>
|
||||
<InputNumber
|
||||
value={customWordCount}
|
||||
onChange={(value) => setCustomWordCount(value || selectedText.length)}
|
||||
min={10}
|
||||
max={10000}
|
||||
step={50}
|
||||
disabled={isGenerating}
|
||||
addonAfter="字"
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
{/* 生成结果展示 */}
|
||||
{(isGenerating || hasGenerated) && (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<Space>
|
||||
<Text strong>重写结果</Text>
|
||||
{generatedText && (
|
||||
<Text type="secondary">({generatedText.length}字)</Text>
|
||||
)}
|
||||
</Space>
|
||||
{isGenerating && (
|
||||
<Space>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />} />
|
||||
<Text type="secondary">{progressMessage || '生成中...'}</Text>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{isGenerating && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
|
||||
width: `${progress}%`,
|
||||
transition: 'width 0.3s ease',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
ref={generatedTextRef}
|
||||
style={{
|
||||
background: generatedText ? '#f6ffed' : '#fafafa',
|
||||
border: generatedText ? '1px solid #b7eb8f' : '1px solid #d9d9d9',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 250,
|
||||
overflowY: 'auto',
|
||||
minHeight: 100,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{generatedText ? (
|
||||
<Paragraph
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{generatedText}
|
||||
{isGenerating && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 16,
|
||||
background: 'var(--color-primary)',
|
||||
marginLeft: 2,
|
||||
animation: 'blink 1s infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: '#8c8c8c' }}>
|
||||
{isGenerating ? '正在生成内容...' : '等待生成...'}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{hasGenerated && generatedText && (
|
||||
<Alert
|
||||
message="生成完成"
|
||||
description={
|
||||
<span>
|
||||
原文 {selectedText.length} 字 → 新文 {generatedText.length} 字
|
||||
{generatedText.length > selectedText.length && (
|
||||
<Text type="success"> (+{generatedText.length - selectedText.length}字)</Text>
|
||||
)}
|
||||
{generatedText.length < selectedText.length && (
|
||||
<Text type="warning"> ({generatedText.length - selectedText.length}字)</Text>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加闪烁光标动画 */}
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PartialRegenerateModal;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
|
||||
interface PartialRegenerateToolbarProps {
|
||||
visible: boolean;
|
||||
position: { top: number; left: number };
|
||||
onRegenerate: () => void;
|
||||
selectedText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 局部重写浮动工具栏
|
||||
* 当用户在章节内容编辑器中选中文本时显示
|
||||
*/
|
||||
export const PartialRegenerateToolbar: React.FC<PartialRegenerateToolbarProps> = ({
|
||||
visible,
|
||||
position,
|
||||
onRegenerate,
|
||||
selectedText
|
||||
}) => {
|
||||
if (!visible || !selectedText) return null;
|
||||
|
||||
// 限制显示的选中文本长度
|
||||
const displayText = selectedText.length > 20
|
||||
? selectedText.substring(0, 20) + '...'
|
||||
: selectedText;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
zIndex: 10000,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.15)',
|
||||
padding: '6px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
animation: 'fadeIn 0.2s ease-out',
|
||||
border: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
title={`AI重写选中内容: "${displayText}"`}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRegenerate();
|
||||
}}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 4px 12px rgba(77, 128, 136, 0.3)',
|
||||
}}
|
||||
>
|
||||
AI重写
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#8c8c8c',
|
||||
maxWidth: 150,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
已选 {selectedText.length} 字
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 添加动画样式
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`;
|
||||
if (!document.head.querySelector('style[data-partial-regenerate-toolbar]')) {
|
||||
style.setAttribute('data-partial-regenerate-toolbar', 'true');
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export default PartialRegenerateToolbar;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
|
||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
@@ -12,6 +12,8 @@ import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
|
||||
import { SSEProgressModal } from '../components/SSEProgressModal';
|
||||
import FloatingIndexPanel from '../components/FloatingIndexPanel';
|
||||
import ChapterReader from '../components/ChapterReader';
|
||||
import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar';
|
||||
import PartialRegenerateModal from '../components/PartialRegenerateModal';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -78,6 +80,14 @@ export default function Chapters() {
|
||||
const [planEditorVisible, setPlanEditorVisible] = useState(false);
|
||||
const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null);
|
||||
|
||||
// 局部重写状态
|
||||
const [partialRegenerateToolbarVisible, setPartialRegenerateToolbarVisible] = useState(false);
|
||||
const [partialRegenerateToolbarPosition, setPartialRegenerateToolbarPosition] = useState({ top: 0, left: 0 });
|
||||
const [selectedTextForRegenerate, setSelectedTextForRegenerate] = useState('');
|
||||
const [selectionStartPosition, setSelectionStartPosition] = useState(0);
|
||||
const [selectionEndPosition, setSelectionEndPosition] = useState(0);
|
||||
const [partialRegenerateModalVisible, setPartialRegenerateModalVisible] = useState(false);
|
||||
|
||||
// 单章节生成进度状态
|
||||
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
|
||||
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
|
||||
@@ -106,6 +116,212 @@ export default function Chapters() {
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 处理文本选中 - 检测选中文本并显示浮动工具栏
|
||||
const handleTextSelection = useCallback(() => {
|
||||
// 只在编辑器打开时处理选中
|
||||
if (!isEditorOpen || isGenerating) {
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedText = selection.toString().trim();
|
||||
|
||||
// 至少选中10个字符才显示工具栏
|
||||
if (selectedText.length < 10) {
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查选中是否在 TextArea 内
|
||||
const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea;
|
||||
if (!textArea) {
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查选中是否在 textarea 内(需要特殊处理,因为 textarea 的选中不会创建 range)
|
||||
if (document.activeElement !== textArea) {
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取 textarea 中的选中位置
|
||||
const start = textArea.selectionStart;
|
||||
const end = textArea.selectionEnd;
|
||||
const textContent = textArea.value;
|
||||
const selectedInTextArea = textContent.substring(start, end);
|
||||
|
||||
if (selectedInTextArea.trim().length < 10) {
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算浮动工具栏位置
|
||||
const rect = textArea.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(textArea);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||
|
||||
// 计算选中文本起始位置所在的行号
|
||||
const textBeforeSelection = textContent.substring(0, start);
|
||||
const startLine = textBeforeSelection.split('\n').length - 1;
|
||||
|
||||
// 计算选中文本在 textarea 中的视觉位置
|
||||
// 需要考虑 scrollTop(textarea 内部滚动偏移)
|
||||
const scrollTop = textArea.scrollTop;
|
||||
const visualTop = (startLine * lineHeight) + paddingTop - scrollTop;
|
||||
|
||||
// 工具栏位置:textarea 顶部 + 选中文本的视觉位置 - 工具栏高度偏移
|
||||
const toolbarTop = rect.top + visualTop - 45;
|
||||
|
||||
// 水平位置:放在 textarea 的右侧区域,避免遮挡文本
|
||||
const toolbarLeft = rect.right - 180;
|
||||
|
||||
setSelectedTextForRegenerate(selectedInTextArea);
|
||||
setSelectionStartPosition(start);
|
||||
setSelectionEndPosition(end);
|
||||
|
||||
// 计算工具栏位置,如果选中位置不在可视区域内,固定在边缘
|
||||
let finalTop = toolbarTop;
|
||||
if (visualTop < 0) {
|
||||
finalTop = rect.top + 10;
|
||||
} else if (visualTop > textArea.clientHeight) {
|
||||
finalTop = rect.bottom - 50;
|
||||
}
|
||||
|
||||
setPartialRegenerateToolbarPosition({
|
||||
top: Math.max(rect.top + 10, Math.min(finalTop, rect.bottom - 50)),
|
||||
left: Math.min(Math.max(rect.left + 20, toolbarLeft), window.innerWidth - 200),
|
||||
});
|
||||
setPartialRegenerateToolbarVisible(true);
|
||||
}, [isEditorOpen, isGenerating]);
|
||||
|
||||
// 更新工具栏位置的函数(不检测选中,只更新位置)
|
||||
const updateToolbarPosition = useCallback(() => {
|
||||
if (!partialRegenerateToolbarVisible || !selectedTextForRegenerate) return;
|
||||
|
||||
const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea;
|
||||
if (!textArea) return;
|
||||
|
||||
const rect = textArea.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(textArea);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||
|
||||
const textContent = textArea.value;
|
||||
const textBeforeSelection = textContent.substring(0, selectionStartPosition);
|
||||
const startLine = textBeforeSelection.split('\n').length - 1;
|
||||
|
||||
const scrollTop = textArea.scrollTop;
|
||||
const visualTop = (startLine * lineHeight) + paddingTop - scrollTop;
|
||||
|
||||
const toolbarTop = rect.top + visualTop - 45;
|
||||
// 固定在 textarea 右上角,不随选中位置变化
|
||||
const toolbarLeft = rect.right - 180;
|
||||
|
||||
// 工具栏固定在 textarea 可视区域内,即使选中文本滚出视野也保持显示
|
||||
// 如果选中位置在可视区域内,跟随选中位置
|
||||
// 如果滚出视野,固定在顶部或底部边缘
|
||||
let finalTop = toolbarTop;
|
||||
if (visualTop < 0) {
|
||||
// 选中位置在上方视野外,工具栏固定在顶部
|
||||
finalTop = rect.top + 10;
|
||||
} else if (visualTop > textArea.clientHeight) {
|
||||
// 选中位置在下方视野外,工具栏固定在底部
|
||||
finalTop = rect.bottom - 50;
|
||||
}
|
||||
|
||||
setPartialRegenerateToolbarPosition({
|
||||
top: Math.max(rect.top + 10, Math.min(finalTop, rect.bottom - 50)),
|
||||
left: Math.min(Math.max(rect.left + 20, toolbarLeft), window.innerWidth - 200),
|
||||
});
|
||||
}, [partialRegenerateToolbarVisible, selectedTextForRegenerate, selectionStartPosition]);
|
||||
|
||||
// 监听选中事件
|
||||
useEffect(() => {
|
||||
if (!isEditorOpen) return;
|
||||
|
||||
const textArea = contentTextAreaRef.current?.resizableTextArea?.textArea;
|
||||
if (!textArea) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 鼠标释放时检查选中
|
||||
setTimeout(handleTextSelection, 50);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
// Shift + 方向键选中时检查
|
||||
if (e.shiftKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||
setTimeout(handleTextSelection, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
// 滚动时更新位置(使用 requestAnimationFrame 优化性能)
|
||||
requestAnimationFrame(updateToolbarPosition);
|
||||
};
|
||||
|
||||
// 监听 textarea 滚动
|
||||
textArea.addEventListener('mouseup', handleMouseUp);
|
||||
textArea.addEventListener('keyup', handleKeyUp);
|
||||
textArea.addEventListener('scroll', handleScroll);
|
||||
|
||||
// 同时监听 Modal body 滚动(Modal 内容可能在外层容器滚动)
|
||||
const modalBody = textArea.closest('.ant-modal-body');
|
||||
if (modalBody) {
|
||||
modalBody.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
return () => {
|
||||
textArea.removeEventListener('mouseup', handleMouseUp);
|
||||
textArea.removeEventListener('keyup', handleKeyUp);
|
||||
textArea.removeEventListener('scroll', handleScroll);
|
||||
if (modalBody) {
|
||||
modalBody.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
}, [isEditorOpen, handleTextSelection, updateToolbarPosition]);
|
||||
|
||||
// 点击其他区域时隐藏工具栏
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// 如果点击的是工具栏,不隐藏
|
||||
if (target.closest('[data-partial-regenerate-toolbar]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果点击的是 textarea,不隐藏
|
||||
if (target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果点击的是 Modal 内部(包括滚动条),不隐藏
|
||||
if (target.closest('.ant-modal-content')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 点击 Modal 外部才隐藏工具栏
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
};
|
||||
|
||||
if (partialRegenerateToolbarVisible) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [partialRegenerateToolbarVisible]);
|
||||
|
||||
const {
|
||||
refreshChapters,
|
||||
updateChapter,
|
||||
@@ -1540,6 +1756,29 @@ export default function Chapters() {
|
||||
}
|
||||
};
|
||||
|
||||
// 打开局部重写弹窗
|
||||
const handleOpenPartialRegenerate = () => {
|
||||
setPartialRegenerateToolbarVisible(false);
|
||||
setPartialRegenerateModalVisible(true);
|
||||
};
|
||||
|
||||
// 应用局部重写结果
|
||||
const handleApplyPartialRegenerate = (newText: string, startPos: number, endPos: number) => {
|
||||
// 获取当前内容
|
||||
const currentContent = editorForm.getFieldValue('content') || '';
|
||||
|
||||
// 替换选中部分
|
||||
const newContent = currentContent.substring(0, startPos) + newText + currentContent.substring(endPos);
|
||||
|
||||
// 更新表单
|
||||
editorForm.setFieldsValue({ content: newContent });
|
||||
|
||||
// 关闭弹窗
|
||||
setPartialRegenerateModalVisible(false);
|
||||
|
||||
message.success('局部重写已应用');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{contextHolder}
|
||||
@@ -2086,7 +2325,7 @@ export default function Chapters() {
|
||||
setIsEditorOpen(false);
|
||||
}}
|
||||
closable={!isGenerating}
|
||||
maskClosable={!isGenerating}
|
||||
maskClosable={false}
|
||||
keyboard={!isGenerating}
|
||||
width={isMobile ? 'calc(100vw - 32px)' : '85%'}
|
||||
centered
|
||||
@@ -2253,6 +2492,16 @@ export default function Chapters() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 局部重写浮动工具栏 */}
|
||||
<div data-partial-regenerate-toolbar>
|
||||
<PartialRegenerateToolbar
|
||||
visible={partialRegenerateToolbarVisible && !isGenerating}
|
||||
position={partialRegenerateToolbarPosition}
|
||||
selectedText={selectedTextForRegenerate}
|
||||
onRegenerate={handleOpenPartialRegenerate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
@@ -2641,6 +2890,20 @@ export default function Chapters() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 局部重写弹窗 */}
|
||||
{editingId && (
|
||||
<PartialRegenerateModal
|
||||
visible={partialRegenerateModalVisible}
|
||||
chapterId={editingId}
|
||||
selectedText={selectedTextForRegenerate}
|
||||
startPosition={selectionStartPosition}
|
||||
endPosition={selectionEndPosition}
|
||||
styleId={selectedStyleId}
|
||||
onClose={() => setPartialRegenerateModalVisible(false)}
|
||||
onApply={handleApplyPartialRegenerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 规划编辑器 */}
|
||||
{editingPlanChapter && currentProject && (() => {
|
||||
let parsedPlanData = null;
|
||||
|
||||
@@ -616,6 +616,45 @@ export const chapterApi = {
|
||||
completed_at: string | null;
|
||||
}>;
|
||||
}>(`/chapters/${chapterId}/regeneration/tasks`, { params: { limit } }),
|
||||
|
||||
// 局部重写相关
|
||||
partialRegenerateStream: (
|
||||
chapterId: string,
|
||||
data: {
|
||||
selected_text: string;
|
||||
start_position: number;
|
||||
end_position: number;
|
||||
user_instructions: string;
|
||||
context_chars?: number;
|
||||
style_id?: number;
|
||||
length_mode?: 'similar' | 'expand' | 'condense' | 'custom';
|
||||
target_word_count?: number;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<{
|
||||
new_text: string;
|
||||
word_count: number;
|
||||
original_word_count: number;
|
||||
start_position: number;
|
||||
end_position: number;
|
||||
}>(
|
||||
`/api/chapters/${chapterId}/partial-regenerate-stream`,
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
applyPartialRegenerate: (chapterId: string, data: {
|
||||
new_text: string;
|
||||
start_position: number;
|
||||
end_position: number;
|
||||
}) =>
|
||||
api.post<unknown, {
|
||||
success: boolean;
|
||||
chapter_id: string;
|
||||
word_count: number;
|
||||
old_word_count: number;
|
||||
message: string;
|
||||
}>(`/chapters/${chapterId}/apply-partial-regenerate`, data),
|
||||
};
|
||||
|
||||
export const writingStyleApi = {
|
||||
|
||||
Reference in New Issue
Block a user