Files
董宏凯andCursor 5de74e90dc feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏
AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 15:53:51 +08:00

214 lines
9.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Writing rewrite API — SSE streaming endpoint.
POST /api/writing/rewrite
Returns text/event-stream with frames:
event: thinking data: {"chunk": "..."}
event: text data: {"chunk": "..."}
event: done data: {}
event: error data: {"message": "..."}
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/writing", tags=["writing"])
# ── Prompts ───────────────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """你是一位专业的中文写作助手,擅长润色、扩写、缩写和调整文风。
规则:
- 只输出改写后的正文,不加任何前缀、解释或括号说明。
- 保持原文的语言(中文输入 → 中文输出,英文输入 → 英文输出)。
- 改写结果应与原文长度相近,除非任务要求扩写或缩写。
- 不要添加 markdown 标题或特殊格式,除非原文本身包含。
"""
_ACTION_INSTRUCTIONS: dict[str, str] = {
"polish": "请对以下文字进行润色,使其表达更流畅、自然,同时保留原意。",
"expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。",
"shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。",
"tone": "请调整以下文字的语气风格。",
"imitate": "请模仿给定范文的写作风格、句式与语气,对以下文字进行仿写改写,在保留原文核心信息的前提下尽量贴近范文的表达方式。",
}
# 仿写参考范文的最大长度(防止超长 prompt)
_REFERENCE_MAX_CHARS = 2000
_TONE_SUFFIX: dict[str, str] = {
"professional": "目标风格:专业、正式。",
"casual": "目标风格:轻松、口语化。",
"readable": "目标风格:通俗易懂、易于阅读。",
"academic": "目标风格:学术严谨、逻辑清晰。",
"expressive": "目标风格:生动有文采、富有感染力。",
}
# ── Request model ─────────────────────────────────────────────────────────────
class SelectionInfo(BaseModel):
from_: int = Field(..., alias="from", description="ProseMirror start offset")
to: int = Field(..., description="ProseMirror end offset")
text: str = Field(..., description="Selected plain text")
model_config = {"populate_by_name": True}
class SurroundingContext(BaseModel):
before: str = Field(default="", description="Text before the selection")
after: str = Field(default="", description="Text after the selection")
class RewriteRequest(BaseModel):
thread_id: str | None = Field(default=None, alias="threadId")
selection: SelectionInfo
action: str = Field(..., description="polish | expand | shorten | tone | imitate")
tone: str | None = Field(default=None)
model_name: str | None = Field(default=None, alias="modelName")
surrounding_context: SurroundingContext = Field(default_factory=SurroundingContext, alias="surroundingContext")
custom_instruction: str | None = Field(default=None, alias="customInstruction")
reference_text: str | None = Field(default=None, alias="referenceText", description="仿写参考范文(action=imitate)")
follow_up_instruction: str | None = Field(default=None, alias="followUpInstruction")
previous_result: str | None = Field(default=None, alias="previousResult")
model_config = {"populate_by_name": True}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
"""Return (thinking_delta, text_delta) from a LangChain streaming chunk."""
thinking = ""
text = ""
content = getattr(chunk, "content", "")
if isinstance(content, str):
text = content
elif isinstance(content, list):
for block in content:
if isinstance(block, str):
text += block
elif isinstance(block, dict):
btype = block.get("type", "")
if btype in ("thinking", "thinking_delta"):
thinking += block.get("thinking", "")
elif btype in ("text", "text_delta"):
text += block.get("text", "")
elif btype == "reasoning":
thinking += block.get("content", "")
# DeepSeek / OpenAI o-series: reasoning in additional_kwargs
if not thinking:
kwargs = getattr(chunk, "additional_kwargs", {}) or {}
r = kwargs.get("reasoning_content") or ""
if isinstance(r, str):
thinking = r
return thinking, text
# ── Prompt building (pure, testable) ──────────────────────────────────────────
def build_rewrite_messages(body: RewriteRequest) -> tuple[str, list]:
"""Build (resolved_action, messages) for the rewrite request.
Pure function (no model / IO) so the prompt assembly — including 仿写
(imitate) reference-sample injection — can be unit-tested directly.
"""
action = body.action if body.action in _ACTION_INSTRUCTIONS else "polish"
instruction = _ACTION_INSTRUCTIONS[action]
if action == "tone" and body.tone and body.tone in _TONE_SUFFIX:
instruction = f"{instruction}\n{_TONE_SUFFIX[body.tone]}"
context_parts: list[str] = []
before = body.surrounding_context.before.strip()
after = body.surrounding_context.after.strip()
if before or after:
context_parts.append("文章上下文(仅供参考,不要改写):")
if before:
context_parts.append(f"[前文] {before[-300:]}")
if after:
context_parts.append(f"[后文] {after[:300]}")
context_parts.append("")
context_block = "\n".join(context_parts)
base_instruction = instruction
if body.custom_instruction:
base_instruction = f"{instruction}\n用户额外要求:{body.custom_instruction}"
if action == "imitate" and body.reference_text and body.reference_text.strip():
reference = body.reference_text.strip()[:_REFERENCE_MAX_CHARS]
base_instruction = f"{base_instruction}\n\n【范文】(请模仿其文风、句式、语气与结构,但不要照抄其具体内容):\n{reference}"
user_message = f"{base_instruction}\n\n{context_block}需要改写的文字:\n{body.selection.text}"
if body.follow_up_instruction and body.previous_result:
messages = [
SystemMessage(content=_SYSTEM_PROMPT),
HumanMessage(content=user_message),
AIMessage(content=body.previous_result),
HumanMessage(content=body.follow_up_instruction),
]
else:
messages = [SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=user_message)]
return action, messages
# ── Endpoint ──────────────────────────────────────────────────────────────────
@router.post(
"/rewrite",
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse,
)
async def rewrite_selection(
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
action, messages = build_rewrite_messages(body)
# Auto-enable thinking when the selected model supports it
resolved_name = body.model_name or (config.models[0].name if config.models else None)
model_cfg = config.get_model_config(resolved_name) if resolved_name else None
thinking_enabled = bool(model_cfg and getattr(model_cfg, "supports_thinking", False))
async def generate():
try:
model = create_chat_model(name=body.model_name, thinking_enabled=thinking_enabled, app_config=config)
async for chunk in model.astream(messages, config={"run_name": "writing_rewrite"}):
thinking_delta, text_delta = _extract_chunk_parts(chunk)
if thinking_delta:
yield _sse("thinking", {"chunk": thinking_delta})
if text_delta:
yield _sse("text", {"chunk": text_delta})
yield _sse("done", {})
except Exception as e:
logger.exception("writing/rewrite failed: thread=%s action=%s", body.thread_id, action)
yield _sse("error", {"message": str(e)})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)