Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ npm run build
#### 后端环境变量 (.env)
```env
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/paperagent
DATABASE_URL=postgresql+psycopg://user:password@localhost:5432/paperagent

# JWT配置
SECRET_KEY=your-secret-key
Expand Down
2 changes: 1 addition & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# 数据库配置
DATABASE_URL=postgresql://username:password@localhost:5432/paperagent
DATABASE_URL=postgresql+psycopg://username:password@localhost:5432/paperagent

# JWT配置
SECRET_KEY=your-secret-key-here
Expand Down
2 changes: 1 addition & 1 deletion backend/ai_system/core_agents/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typing import List, Dict, Any, Callable, Optional
from abc import ABC, abstractmethod
from datetime import datetime
from langchain.agents import create_openai_tools_agent, AgentExecutor
from langchain.agents import create_agent
from langchain_core.prompts import ChatPromptTemplate

from ..core_managers.context_manager import ContextManager
Expand Down
196 changes: 124 additions & 72 deletions backend/ai_system/core_agents/main_agent.py

Large diffs are not rendered by default.

41 changes: 26 additions & 15 deletions backend/ai_system/core_agents/writer_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,24 +201,35 @@ def _get_system_prompt(self) -> str:
return base_prompt + (
"**输出模式:Markdown (.md)**\n\n"
"你可以使用以下Markdown工具:\n"
"1. writemd - 写入Markdown内容(支持append、overwrite、modify等模式)\n"
"2. update_template - 更新模板章节\n\n"
"1. get_paper_status - 获取论文写作状态概览(章节结构、进度、摘要)\n"
"2. readmd - 读取Markdown文件完整内容\n"
"3. writemd - 写入Markdown内容(支持多种模式)\n"
"4. update_template - 更新模板中的特定章节\n\n"
"**⚠️ 重要:开始写作前必须先了解文档当前状态**\n"
"在进行任何写作操作之前,你必须首先调用 get_paper_status 或 readmd 来了解 paper.md 的现有内容和结构。\n"
"**绝对禁止在不读取文件的情况下直接写入!**\n\n"
"**writemd 写入模式选择指南(关键!)**:\n"
"- **更新已有章节** → mode='section_update'(推荐!content必须以#标题开头,只替换该章节)\n"
"- **添加全新章节** → mode='append'(在文件末尾追加,不影响已有内容)\n"
"- **重建整个文件** → mode='overwrite'(⚠️危险!会删除所有已有内容,极少使用)\n\n"
"**🔴 防止内容丢失的核心规则**:\n"
"1. 每次写作前必须先调用 get_paper_status 查看当前状态\n"
"2. 如果章节已有内容,使用 section_update 或 update_template 更新,不要用 overwrite\n"
"3. 添加新章节时使用 append 模式,不要用 overwrite\n"
"4. 只有在需要完全重建论文时才使用 overwrite 模式\n"
"5. 写入的内容不要包含其他已存在章节的内容,只写当前目标章节\n\n"
"**工作流程示例**:\n"
"收到指令:\"写一个Introduction章节,介绍研究背景\"\n"
"你应该:\n"
"1. 思考:Introduction应该包含什么内容?\n"
"2. 创作具体的Markdown内容:\n"
" ```markdown\n"
" # Introduction\n"
" \n"
" 研究背景的第一段...\n"
" \n"
" 研究背景的第二段...\n"
" \n"
" 本文的研究意义...\n"
" ```\n"
"3. 调用 writemd 或 update_template 工具写入\n"
"4. 确认完成并报告\n\n"
"1. **首先获取论文状态**:\n"
" - 调用 get_paper_status() 查看哪些章节已写、哪些未写\n"
"2. 思考:Introduction应该包含什么内容?\n"
"3. 创作具体的Markdown内容\n"
"4. **选择正确的写入模式**:\n"
" - 如果Introduction章节已存在但内容为空 → 用 update_template 或 writemd(mode='section_update')\n"
" - 如果Introduction章节不存在 → 用 writemd(mode='append')\n"
" - 如果需要重写Introduction → 用 writemd(mode='section_update')\n"
"5. 确认完成并报告\n\n"
"**数学公式渲染规则**:\n"
"- **行内公式**:使用单个 $ 符号包裹,例如:$E = mc^2$\n"
"- **独立行公式**:使用双 $$ 符号包裹,例如:\n"
Expand Down
46 changes: 45 additions & 1 deletion backend/ai_system/core_handlers/llm_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import logging
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, Mapping, cast
from langchain_core.language_models import BaseLanguageModel
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
Expand All @@ -14,6 +14,50 @@
logger = logging.getLogger(__name__)


def _patch_reasoning_content():
"""
Monkey-patch langchain-openai to preserve reasoning_content
for thinking-mode models (DeepSeek-R1, Volcengine thinking, etc.).

langchain-openai (as of 1.2.1) strips reasoning_content from API
responses, causing 400 errors on subsequent tool-calling turns because
the API requires it to be passed back.
"""
import langchain_openai.chat_models.base as _base

_orig_dict_to_msg = _base._convert_dict_to_message
_orig_msg_to_dict = _base._convert_message_to_dict
_orig_delta_to_chunk = _base._convert_delta_to_message_chunk

def _patched_dict_to_message(_dict: Mapping[str, Any]):
msg = _orig_dict_to_msg(_dict)
if _dict.get("role") == "assistant" and _dict.get("reasoning_content"):
msg.additional_kwargs["reasoning_content"] = _dict["reasoning_content"]
return msg

def _patched_message_to_dict(message, api="chat/completions"):
d = _orig_msg_to_dict(message, api)
if d.get("role") == "assistant":
rc = getattr(message, "additional_kwargs", {}).get("reasoning_content")
if rc:
d["reasoning_content"] = rc
return d

def _patched_delta_to_chunk(_dict: Mapping[str, Any], default_class):
chunk = _orig_delta_to_chunk(_dict, default_class)
if _dict.get("reasoning_content"):
chunk.additional_kwargs["reasoning_content"] = _dict["reasoning_content"]
return chunk

_base._convert_dict_to_message = _patched_dict_to_message
_base._convert_message_to_dict = _patched_message_to_dict
_base._convert_delta_to_message_chunk = _patched_delta_to_chunk
logger.info("reasoning_content monkey-patch applied to langchain-openai")


_patch_reasoning_content()


class BaseLLMProvider(ABC):
"""AI提供商抽象基类"""

Expand Down
54 changes: 50 additions & 4 deletions backend/ai_system/core_managers/langchain_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,47 @@ def create_file_tools(workspace_dir: str, stream_manager=None) -> List[Structure
LangChain 格式的文件工具列表
"""
try:
# 设置环境变量供 FileTools 使用
os.environ["WORKSPACE_DIR"] = workspace_dir
file_tools = FileTools(stream_manager)

_SAFE_MODES = {"section_update", "append", "insert", "smart_replace"}

def safe_writemd(filename: str, content: str, mode: str = "section_update") -> str:
if mode == "overwrite":
file_path = os.path.join(workspace_dir, filename if filename.endswith('.md') else filename + '.md')
if os.path.exists(file_path) and os.path.getsize(file_path) > 500:
logger.warning(
f"safe_writemd: 拦截了对 {filename} 的 overwrite 操作 "
f"(已有 {os.path.getsize(file_path)} 字节),自动降级为 section_update"
)
mode = "section_update"
if mode == "modify":
mode = "section_update"
return file_tools.writemd(filename=filename, content=content, mode=mode)

tools = [
StructuredTool.from_function(
func=file_tools.writemd,
func=file_tools.get_paper_status,
name="get_paper_status",
description="获取paper.md的写作状态概览,包括章节结构、各章节字数和内容摘要、写作进度。在写作任何内容之前必须先调用此工具了解当前论文状态,避免重复写作或覆盖已有内容。"
),
StructuredTool.from_function(
func=file_tools.readmd,
name="readmd",
description="读取Markdown文件内容。参数:filename(文件名,默认paper)。在写入或更新paper.md之前必须先调用此工具了解现有内容,避免覆盖或重复。"
),
StructuredTool.from_function(
func=safe_writemd,
name="writemd",
description="写入Markdown文件到工作空间。支持多种模式:append(追加)、overwrite(覆盖)、modify(修改)、insert(插入)、section_update(章节更新)"
description=(
"写入Markdown文件到工作空间。参数:filename(文件名)、content(内容)、mode(写入模式)。\n"
"⚠️ 写入前必须先调用readmd读取现有内容!\n"
"支持的mode:\n"
"- section_update(推荐、默认):章节级更新,content必须以#标题开头,只替换该章节内容,不影响其他章节\n"
"- append:在文件末尾追加内容,适合添加全新章节\n"
"- insert:在文件开头插入内容\n"
"⚠️ 禁止使用overwrite模式,会导致已有内容丢失"
)
),
StructuredTool.from_function(
func=file_tools.update_template,
Expand Down Expand Up @@ -223,7 +255,7 @@ def create_template_tools(workspace_dir: str, stream_manager=None) -> List[Struc
LangChain 格式的模板工具列表
"""
try:
template_tools = TemplateAgentTools(workspace_dir)
template_tools = TemplateAgentTools(workspace_dir, stream_manager)

tools = [
StructuredTool.from_function(
Expand Down Expand Up @@ -509,6 +541,20 @@ def create_base_tools(workspace_dir: str, stream_manager=None) -> List[BaseTool]
file_tools_instance = FileTools(stream_manager)

base_tools = [
StructuredTool.from_function(
func=file_tools_instance.get_paper_status,
name="get_paper_status",
description="获取paper.md的写作状态概览,包括章节结构、各章节字数和内容摘要、写作进度。在委派任何写作任务前必须先调用此工具了解论文当前状态。"
),
StructuredTool.from_function(
func=file_tools_instance.update_plan,
name="update_plan",
description=(
"更新论文写作计划(plan.md),用户可在前端实时看到计划进度。\n"
"Phase 2制定计划后立即调用保存计划,Phase 3每完成一个章节后更新计划状态。\n"
"计划内容使用Markdown表格格式,包含:序号、章节名、状态(✅已完成/⏳进行中/⬜待写)、说明。"
)
),
StructuredTool.from_function(
func=file_tools_instance.tree,
name="tree",
Expand Down
87 changes: 80 additions & 7 deletions backend/ai_system/core_tools/code_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import logging
import os
import re
import sys
import tempfile
import subprocess
Expand All @@ -14,7 +15,7 @@
from datetime import datetime
from typing import Dict, Any, Optional
import matplotlib
matplotlib.use('Agg') # 非交互式后端
matplotlib.use('Agg')
import matplotlib.pyplot as plt

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -59,9 +60,9 @@ async def execute_code(self, code_content: str) -> str:
# 直接在子进程中执行代码
result = await self._execute_code_directly(code_content)

# 发送执行结果
if self.stream_manager:
await self.stream_manager.print_code_execution_result(result)
await self.stream_manager.send_json_block("file_changed", "code_execution")

return result

Expand Down Expand Up @@ -221,10 +222,10 @@ async def _save_code_only(self, code_content: str, filename: str) -> str:

logger.info(f"代码已保存到文件: {file_path}")

# 通过stream_manager发送工具调用通知到前端
if self.stream_manager:
try:
await self.stream_manager.print_code_execution_call(f"代码文件 {safe_filename} 保存成功")
await self.stream_manager.send_json_block("file_changed", safe_filename)
except Exception as e:
logger.warning(f"发送工具调用通知失败: {e}")

Expand All @@ -244,6 +245,40 @@ def _prepare_code_for_subprocess(self, code: str) -> str:
matplotlib.use('Agg') # 非交互式后端
import matplotlib.pyplot as plt

# 配置中文字体支持
import platform
_system = platform.system()
_cn_font_found = False
if _system == 'Darwin':
_cn_candidates = ['PingFang SC', 'Heiti SC', 'STHeiti', 'Songti SC', 'Arial Unicode MS']
elif _system == 'Windows':
_cn_candidates = ['Microsoft YaHei', 'SimHei', 'SimSun', 'FangSong', 'KaiTi']
else:
_cn_candidates = ['WenQuanYi Micro Hei', 'WenQuanYi Zen Hei', 'Noto Sans CJK SC', 'Noto Sans SC', 'Source Han Sans SC', 'AR PL UMing CN', 'Droid Sans Fallback']

from matplotlib.font_manager import fontManager as _fm
_available = set(f.name for f in _fm.ttflist)
for _font in _cn_candidates:
if _font in _available:
matplotlib.rcParams['font.sans-serif'] = [_font] + matplotlib.rcParams.get('font.sans-serif', [])
_cn_font_found = True
break

if not _cn_font_found:
_cjk = [f.name for f in _fm.ttflist if any(kw in f.name.lower() for kw in ['cjk', 'chinese', 'hei', 'song', 'fang', 'kai', 'ming', 'gothic', 'pingfang', 'yahei', 'noto sans sc'])]
if _cjk:
matplotlib.rcParams['font.sans-serif'] = [_cjk[0]] + matplotlib.rcParams.get('font.sans-serif', [])

matplotlib.rcParams['axes.unicode_minus'] = False

_saved_cn_font = list(matplotlib.rcParams['font.sans-serif'])
_original_style_use = plt.style.use
def _patched_style_use(*args, **kwargs):
_original_style_use(*args, **kwargs)
matplotlib.rcParams['font.sans-serif'] = _saved_cn_font
matplotlib.rcParams['axes.unicode_minus'] = False
plt.style.use = _patched_style_use

# 设置工作空间目录
os.chdir(r"{self.workspace_dir}") # 改变当前工作目录

Expand Down Expand Up @@ -328,8 +363,49 @@ def _disabled_show(*args, **kwargs):
print(f"\\n执行日志已保存到: {current_log_file}")
'''

code = self._strip_user_font_config(code)
return header + code + footer

@staticmethod
def _strip_user_font_config(code: str) -> str:
code = re.sub(
r"^[^\S\n]*matplotlib\.rcParams\s*\[\s*['\"]font\.sans-serif['\"]\s*\].*$",
"",
code,
flags=re.MULTILINE,
)
code = re.sub(
r"^[^\S\n]*matplotlib\.rcParams\s*\[\s*['\"]axes\.unicode_minus['\"]\s*\].*$",
"",
code,
flags=re.MULTILINE,
)
code = re.sub(
r"^[^\S\n]*plt\.rcParams\s*\[\s*['\"]font\.sans-serif['\"]\s*\].*$",
"",
code,
flags=re.MULTILINE,
)
code = re.sub(
r"^[^\S\n]*plt\.rcParams\s*\[\s*['\"]axes\.unicode_minus['\"]\s*\].*$",
"",
code,
flags=re.MULTILINE,
)
code = re.sub(
r"^[^\S\n]*rcParams\s*\[\s*['\"]font\.sans-serif['\"]\s*\].*$",
"",
code,
flags=re.MULTILINE,
)
code = re.sub(
r"^[^\S\n]*rcParams\s*\[\s*['\"]axes\.unicode_minus['\"]\s*\].*$",
"",
code,
flags=re.MULTILINE,
)
return code

async def _run_in_subprocess(self, temp_file: str) -> str:
"""在子进程中运行代码(异步版本,不阻塞事件循环)"""
try:
Expand Down Expand Up @@ -465,14 +541,11 @@ async def edit_code_file(self, filename: str, new_code_content: str) -> str:

logger.info(f"代码文件已修改: {file_path}")

# 通过stream_manager发送工具调用通知到前端
if self.stream_manager:
try:
# 发送工具调用开始通知
await self.stream_manager.send_json_block("code_agent_tool_call", f"CodeAgent正在执行工具调用: edit_code_file")

# 发送工具调用结果通知
await self.stream_manager.send_json_block("code_agent_tool_result", f"代码文件 {safe_filename} 修改成功")
await self.stream_manager.send_json_block("file_changed", safe_filename)
except Exception as e:
logger.warning(f"发送工具调用通知失败: {e}")

Expand Down
Loading
Loading