From fe94dc3a5139b18c303e205f5c4e5c9f8dba0c41 Mon Sep 17 00:00:00 2001 From: xiamuceer-j Date: Thu, 29 Jan 2026 15:33:43 +0800 Subject: [PATCH] =?UTF-8?q?feature=EF=BC=9A=E6=96=B0=E5=A2=9E=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E5=86=85=E5=AE=B9-=E5=B1=80=E9=83=A8=E9=87=8D?= =?UTF-8?q?=E5=86=99=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/chapters.py | 336 ++++++++++++- backend/app/schemas/chapter.py | 49 +- backend/app/services/prompt_service.py | 75 ++- .../src/components/PartialRegenerateModal.tsx | 450 ++++++++++++++++++ .../components/PartialRegenerateToolbar.tsx | 103 ++++ frontend/src/pages/Chapters.tsx | 267 ++++++++++- frontend/src/services/api.ts | 39 ++ 7 files changed, 1314 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/PartialRegenerateModal.tsx create mode 100644 frontend/src/components/PartialRegenerateToolbar.tsx diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 19d2917..667543c 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -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": "局部重写已应用" + } + diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py index 4819bf4..19fb44f 100644 --- a/backend/app/schemas/chapter.py +++ b/backend/app/schemas/chapter.py @@ -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="响应消息") \ No newline at end of file + 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="响应消息") \ No newline at end of file diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index 0f9e7e1..4696278 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -2083,6 +2083,72 @@ class PromptService: ❌ 所有职业使用相同的阶段数 ❌ 输出markdown标记 ❌ 职业设计与世界观脱节 +""" + + # 局部重写提示词(RTCO框架) + PARTIAL_REGENERATE = """ +你是一位专业的小说改写助手,擅长根据用户的修改要求精准改写指定段落,同时确保与前后文无缝衔接。 + + + +【改写任务】 +根据用户的修改要求,重写下面选中的文本段落。 + +【重要要求】 +1. 只输出重写后的内容,不要包含任何解释、前缀或后缀 +2. 保持与前后文的自然衔接和语气连贯 +3. 严格遵循用户的修改要求 +4. 保持整体叙事风格的一致性 + + + +【前文参考】(用于衔接,勿重复) +{context_before} + +【需要重写的原文】(共{original_word_count}字) +{selected_text} + +【后文参考】(用于衔接,勿重复) +{context_after} + + + +【用户修改要求】 +{user_instructions} + +【字数要求】 +{length_requirement} + + + + + +【输出规范】 +直接输出重写后的内容,从故事内容开始写。 +- 不要输出任何解释或说明文字 +- 不要输出"重写后:"等前缀 +- 不要输出引号包裹内容 +- 确保输出内容可以直接替换原文 + +请直接输出重写后的内容: + + + +【必须遵守】 +✅ 前后衔接:输出内容必须与前文自然衔接,与后文平滑过渡 +✅ 风格一致:保持与原文相同的叙事风格、语气和人称 +✅ 要求优先:严格执行用户的修改要求 +✅ 字数控制:遵循字数要求 + +【禁止事项】 +❌ 重复前文内容 +❌ 重复后文内容 +❌ 添加任何元信息或说明 +❌ 改变叙事人称或视角 +❌ 偏离用户的修改要求 """ @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": "情节分析", diff --git a/frontend/src/components/PartialRegenerateModal.tsx b/frontend/src/components/PartialRegenerateModal.tsx new file mode 100644 index 0000000..cb1f462 --- /dev/null +++ b/frontend/src/components/PartialRegenerateModal.tsx @@ -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 = ({ + visible, + chapterId, + selectedText, + startPosition, + endPosition, + styleId, + onClose, + onApply, +}) => { + const [userInstructions, setUserInstructions] = useState(''); + const [lengthMode, setLengthMode] = useState('similar'); + const [customWordCount, setCustomWordCount] = useState(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(null); + const generatedTextRef = useRef(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 = { + similar: '保持与原文相近的长度', + expand: '扩展内容,增加更多细节', + condense: '精简内容,保留核心要点', + custom: '指定目标字数', + }; + return descriptions[mode]; + }; + + return ( + + + AI局部重写 + + } + open={visible} + onCancel={handleCancel} + width={800} + centered + maskClosable={!isGenerating} + closable={!isGenerating} + keyboard={!isGenerating} + footer={ + + + {!hasGenerated ? ( + + ) : ( + <> + + + + )} + + } + styles={{ + body: { + maxHeight: 'calc(100vh - 200px)', + overflowY: 'auto', + }, + }} + > + {/* 原文展示 */} + + 原文内容 + ({selectedText.length}字) + + } + style={{ marginBottom: 16 }} + styles={{ + body: { + maxHeight: 150, + overflowY: 'auto', + background: '#fafafa', + }, + }} + > + + {selectedText} + + + + {/* 重写要求输入 */} +
+ + 重写要求 * + +