Skip to content

Commit 8e02549

Browse files
songhahaha66claudehappy-otter
authored
feat(backend): add rename_section_title tool for AI template management and docx export (#132)
* fix(frontend): enable LAN access for Vite dev server Configure Vite to bind to 0.0.0.0 instead of localhost, allowing other devices on the local network to access the development server on port 5173. * feat(ai): add rename_section_title tool for AI template management - Add rename_section_title method to TemplateAgentTools class - Register new tool in MainAgent with proper schema and function mapping - Enable AI agents to rename section titles while preserving header hierarchy - Support fuzzy matching for old title identification - Provide clear success/error feedback messages 🤖 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <[email protected]> Co-Authored-By: Happy <[email protected]> * feat: add docx export functionality to workspace export - Add python-docx and markdown dependencies to pyproject.toml - Implement _generate_docx_from_paper() method to convert paper.md to docx - Add _convert_markdown_to_docx() method with markdown parsing and word formatting - Add _process_markdown_line() helper method for markdown tag processing - Modify export_workspace() to automatically include generated paper.docx in zip export - Add graceful error handling for missing dependencies or generation failures - Support academic paper formatting with Times New Roman font and proper heading styles Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <[email protected]> Co-Authored-By: Happy <[email protected]> --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Happy <[email protected]>
1 parent a53f200 commit 8e02549

File tree

5 files changed

+193
-3
lines changed

5 files changed

+193
-3
lines changed

backend/ai_system/core_agents/main_agent.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,29 @@ def _setup_tools(self):
238238
},
239239
}
240240

241+
# rename_section_title工具
242+
rename_section_title_tool = {
243+
"type": "function",
244+
"function": {
245+
"name": "rename_section_title",
246+
"description": "修改paper.md文件中指定章节的标题,保持标题层级不变",
247+
"parameters": {
248+
"type": "object",
249+
"properties": {
250+
"old_title": {"type": "string", "description": "原章节标题(支持模糊匹配)"},
251+
"new_title": {"type": "string", "description": "新章节标题"}
252+
},
253+
"required": ["old_title", "new_title"],
254+
},
255+
},
256+
}
257+
241258
tools.extend([
242259
analyze_template_tool,
243260
get_section_content_tool,
244261
update_section_content_tool,
245-
add_section_tool
262+
add_section_tool,
263+
rename_section_title_tool
246264
])
247265

248266
self.tools = tools
@@ -270,7 +288,8 @@ def _register_tool_functions(self):
270288
"analyze_template": template_tool.analyze_template,
271289
"get_section_content": template_tool.get_section_content,
272290
"update_section_content": template_tool.update_section_content,
273-
"add_section": template_tool.add_section
291+
"add_section": template_tool.add_section,
292+
"rename_section_title": template_tool.rename_section_title
274293
})
275294

276295
async def _execute_code_agent_wrapper(self, task_prompt: str) -> str:

backend/ai_system/core_tools/template_tools.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,58 @@ async def add_section(self, section_title: str, content: str = "") -> str:
236236

237237
except Exception as e:
238238
logger.error(f"添加章节失败: {e}")
239-
return f"❌ 添加章节失败: {str(e)}"
239+
return f"❌ 添加章节失败: {str(e)}"
240+
241+
async def rename_section_title(self, old_title: str, new_title: str) -> str:
242+
"""修改paper.md文件中指定章节的标题"""
243+
template_content = self._read_paper_md()
244+
if not template_content:
245+
return "错误:当前工作目录中没有找到paper.md文件"
246+
247+
try:
248+
import re
249+
lines = template_content.split('\n')
250+
result_lines = []
251+
title_found = False
252+
original_title = ""
253+
i = 0
254+
255+
while i < len(lines):
256+
line = lines[i]
257+
stripped_line = line.strip()
258+
259+
# 检查是否是目标标题
260+
if (stripped_line.startswith('#') and
261+
old_title.lower() in stripped_line.lower()):
262+
263+
# 使用正则表达式提取标题信息
264+
header_match = re.match(r'^(#{1,6})\s+(.+)$', stripped_line)
265+
if header_match:
266+
level = header_match.group(1) # 保持原标题层级
267+
original_title = header_match.group(2).strip()
268+
269+
# 创建新的标题行
270+
new_line = f"{level} {new_title}"
271+
result_lines.append(new_line)
272+
title_found = True
273+
i += 1
274+
continue
275+
276+
result_lines.append(line)
277+
i += 1
278+
279+
if not title_found:
280+
return f"❌ 未找到匹配的标题: {old_title}"
281+
282+
# 保存修改后的内容
283+
updated_content = '\n'.join(result_lines)
284+
save_result = self._save_paper_md(updated_content)
285+
286+
if "✅" in save_result:
287+
return f"✅ 标题修改成功:\"{original_title}\"\"{new_title}\""
288+
else:
289+
return f"❌ 标题修改失败: {save_result}"
290+
291+
except Exception as e:
292+
logger.error(f"修改标题失败: {e}")
293+
return f"❌ 修改标题失败: {str(e)}"

backend/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ dependencies = [
2525
"pandas>=2.3.1",
2626
"seaborn>=0.13.2",
2727
"alembic>=1.16.5",
28+
"python-docx>=1.1.2",
29+
"markdown>=3.6.0",
2830
]

backend/services/file_services/workspace_files.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,25 @@
33
import shutil
44
import zipfile
55
import tempfile
6+
import logging
67
from pathlib import Path
78
from fastapi import HTTPException, status, UploadFile
89
from typing import List, Dict, Any, Optional
910
from .file_helper import FileHelper
1011
from ..data_services.utils import handle_service_errors
1112

13+
logger = logging.getLogger(__name__)
14+
15+
try:
16+
from docx import Document
17+
from docx.shared import Pt, Inches
18+
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
19+
from docx.enum.style import WD_STYLE_TYPE
20+
DOCX_AVAILABLE = True
21+
except ImportError:
22+
logger.warning("python-docx not available. DOCX export will be disabled.")
23+
DOCX_AVAILABLE = False
24+
1225
class WorkspaceFileService:
1326
def __init__(self):
1427
self.base_path = Path("../pa_data/workspaces")
@@ -341,6 +354,18 @@ def export_workspace(self, work_id: str) -> str:
341354
arcname = os.path.relpath(file_path, workspace_path)
342355
zip_file.write(file_path, arcname)
343356

357+
# 生成并添加docx文件
358+
try:
359+
docx_content = self._generate_docx_from_paper(work_id)
360+
if docx_content:
361+
zip_file.writestr("paper.docx", docx_content)
362+
logger.info(f"Successfully added paper.docx to workspace {work_id} export")
363+
else:
364+
logger.info(f"No paper.md found or docx generation failed for workspace {work_id}")
365+
except Exception as e:
366+
logger.error(f"Failed to generate docx for workspace {work_id}: {str(e)}")
367+
# 继续导出其他文件,不因docx生成失败而中断整个导出过程
368+
344369
# 如果工作空间为空,添加一个空的README文件
345370
if not os.listdir(workspace_path):
346371
zip_file.writestr("README.txt", "This workspace is empty.")
@@ -355,5 +380,90 @@ def export_workspace(self, work_id: str) -> str:
355380
detail=f"Failed to export workspace: {str(e)}"
356381
)
357382

383+
def _generate_docx_from_paper(self, work_id: str) -> Optional[bytes]:
384+
"""生成paper.md对应的docx文件内容"""
385+
if not DOCX_AVAILABLE:
386+
logger.warning("python-docx not available, skipping docx generation")
387+
return None
388+
389+
try:
390+
workspace_path = self.ensure_workspace_exists(work_id)
391+
paper_md_path = workspace_path / "paper.md"
392+
393+
if not paper_md_path.exists():
394+
logger.info(f"paper.md not found in workspace {work_id}, skipping docx generation")
395+
return None
396+
397+
# 读取paper.md内容
398+
with open(paper_md_path, 'r', encoding='utf-8') as f:
399+
markdown_content = f.read()
400+
401+
# 转换为docx
402+
docx_content = self._convert_markdown_to_docx(markdown_content)
403+
return docx_content
404+
405+
except Exception as e:
406+
logger.error(f"Failed to generate docx for workspace {work_id}: {str(e)}")
407+
return None
408+
409+
def _convert_markdown_to_docx(self, markdown_content: str) -> bytes:
410+
"""将Markdown内容转换为docx格式"""
411+
try:
412+
# 创建Word文档
413+
doc = Document()
414+
415+
# 设置默认字体
416+
style = doc.styles['Normal']
417+
font = style.font
418+
font.name = 'Times New Roman'
419+
font.size = Pt(12)
420+
421+
# 分割Markdown内容为行
422+
lines = markdown_content.split('\n')
423+
424+
i = 0
425+
while i < len(lines):
426+
line = lines[i].strip()
427+
428+
# 处理标题
429+
if line.startswith('# '):
430+
heading = doc.add_heading(line[2:], level=1)
431+
heading.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
432+
elif line.startswith('## '):
433+
heading = doc.add_heading(line[3:], level=2)
434+
elif line.startswith('### '):
435+
heading = doc.add_heading(line[4:], level=3)
436+
elif line.startswith('#### '):
437+
heading = doc.add_heading(line[5:], level=4)
438+
elif line.startswith('##### '):
439+
heading = doc.add_heading(line[6:], level=5)
440+
# 处理空行
441+
elif line == '':
442+
doc.add_paragraph()
443+
# 处理普通段落
444+
else:
445+
# 简单的Markdown格式处理
446+
processed_line = self._process_markdown_line(line)
447+
paragraph = doc.add_paragraph(processed_line)
448+
449+
i += 1
450+
451+
# 保存到内存中的字节流
452+
from io import BytesIO
453+
doc_stream = BytesIO()
454+
doc.save(doc_stream)
455+
return doc_stream.getvalue()
456+
457+
except Exception as e:
458+
logger.error(f"Failed to convert markdown to docx: {str(e)}")
459+
raise
460+
461+
def _process_markdown_line(self, line: str) -> str:
462+
"""处理单行Markdown格式,转换为纯文本"""
463+
# 简单处理,去除一些常见的Markdown标记
464+
line = line.replace('**', '').replace('*', '').replace('`', '')
465+
line = line.replace('[', '').replace(']', '').replace('(', '').replace(')', '')
466+
return line
467+
358468
# 创建全局实例
359469
workspace_file_service = WorkspaceFileService()

frontend/vite.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@ export default defineConfig({
1515
'@': fileURLToPath(new URL('./src', import.meta.url))
1616
},
1717
},
18+
server: {
19+
host: '0.0.0.0', // 允许局域网访问
20+
port: 5173, // 明确指定端口
21+
strictPort: true, // 如果端口被占用则失败而不是尝试下一个端口
22+
},
1823
})

0 commit comments

Comments
 (0)