feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏

AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-27 15:53:51 +08:00
1 parent c11307aa67
commit 5de74e90dc
21 files changed
+1379 -386

No files matched your search

@@ -42,8 +42,12 @@ _ACTION_INSTRUCTIONS: dict[str, str] = {
"expand": "请对以下文字进行扩写,补充细节和说明,使内容更丰富饱满。",
"shorten": "请对以下文字进行缩写,保留核心意思,去除冗余表达,使其更简洁。",
"tone": "请调整以下文字的语气风格。",
"imitate": "请模仿给定范文的写作风格、句式与语气,对以下文字进行仿写改写,在保留原文核心信息的前提下尽量贴近范文的表达方式。",
}
# 仿写参考范文的最大长度(防止超长 prompt)
_REFERENCE_MAX_CHARS = 2000
_TONE_SUFFIX: dict[str, str] = {
"professional": "目标风格:专业、正式。",
"casual": "目标风格:轻松、口语化。",
@@ -72,11 +76,12 @@ class SurroundingContext(BaseModel):
class RewriteRequest(BaseModel):
thread_id: str | None = Field(default=None, alias="threadId")
selection: SelectionInfo
action: str = Field(..., description="polish | expand | shorten | tone")
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")
@@ -121,18 +126,15 @@ def _extract_chunk_parts(chunk: object) -> tuple[str, str]:
return thinking, text
# ── Endpoint ──────────────────────────────────────────────────────────────────
# ── Prompt building (pure, testable) ──────────────────────────────────────────
@router.post(
"/rewrite",
summary="AI Text Rewrite (SSE stream)",
response_class=StreamingResponse,
)
async def rewrite_selection(
body: RewriteRequest,
config: AppConfig = Depends(get_config),
) -> StreamingResponse:
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:
@@ -153,6 +155,9 @@ async def rewrite_selection(
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:
@@ -165,6 +170,23 @@ async def rewrite_selection(
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
@@ -440,6 +440,7 @@ ai_writing:
# 接口契约:POST {url} 体 {"query","knowledge_base_name","ts":"yes"} → [{"page_content","metadata":{m_title,content,recUuid,m_publish,source1}}]
intranet_search_url: "" # 例如 http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: "samples" # 兜底检索用的知识库名
intranet_search_timeout: 300 # 内网检索超时时间(秒),代码层强制最低 300;需要更长可调大
# 内网接口诊断开关:开启后即使技能已检索成功,也照样把内网接口跑完并把执行结果
# (端点/每个检索词命中数/报错/原始响应片段)记到 material_package.intranet_debug 里,
# 便于在接口返回(devtools)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
@@ -594,6 +594,7 @@ async def run_skill_search_agent(
results: list = []
seen_urls: set[str] = set()
last_error = ""
search_timeout = max(timeout_s, _REAL_AGENT_TIMEOUT_SECONDS)
for _turn in range(_MAX_TURNS):
try:
@@ -616,10 +617,10 @@ async def run_skill_search_agent(
emit_query(query)
try:
found = await asyncio.wait_for(
provider.search(query, max_results=max_results), timeout=timeout_s
provider.search(query, max_results=max_results), timeout=search_timeout
)
except asyncio.TimeoutError:
last_error = f"检索超时(>{timeout_s}s)"
last_error = f"检索超时(>{search_timeout}s)"
messages.append(ToolMessage(content=last_error, tool_call_id=tc_id))
continue
except Exception as e: # noqa: BLE001
@@ -102,6 +102,14 @@ _KNOWLEDGE_SKILL_DISPLAY = "知识库检索"
_KNOWLEDGE_SKILL_FALLBACK_DESC = "对写作意图提炼的检索词执行 web/intranet 知识库检索,获取相关素材"
def _intranet_timeout_seconds(ai_writing_config: dict) -> int:
"""内网检索超时统一入口:默认 10 分钟,任何配置都至少 5 分钟。"""
from ..search.intranet_stub import normalize_intranet_timeout
raw = (ai_writing_config or {}).get("intranet_search_timeout")
return normalize_intranet_timeout(raw)
def _fallback_knowledge_skill() -> SimpleNamespace:
"""技能目录里找不到 knowledge-base-search 时的内存兜底(原生路径不依赖 SKILL.md)。"""
return SimpleNamespace(name=KNOWLEDGE_SEARCH_SKILL_NAME, description=_KNOWLEDGE_SKILL_FALLBACK_DESC)
@@ -581,8 +589,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
provider = IntranetSearchProvider(
base_url=intranet_url, # 空串 → provider 内部回退默认内网端点(旧逻辑)
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
# 内网慢检索超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,默认 600s。
timeout=ai_writing_config.get("intranet_search_timeout", 600),
# 内网慢检索超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
# 默认 600s,且最低 300s。
timeout=_intranet_timeout_seconds(ai_writing_config),
)
endpoint_label = intranet_url or getattr(provider, "endpoint", "内置默认内网端点")
# 用户输入框追加(is_user_query_search)时 keywords 已是 [用户原话],全部检索;
@@ -833,7 +842,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"configured": bool(intranet_url),
"endpoint": intranet_url or "(未配置 ai_writing.intranet_search_url)",
"knowledge_base": ai_writing_config.get("intranet_knowledge_base", "samples"),
"timeout": ai_writing_config.get("intranet_search_timeout", 600),
"timeout": _intranet_timeout_seconds(ai_writing_config),
"debug_force": intranet_debug_force,
"status": "pending" if intranet_url else "not_configured",
"queries": [], # 每个检索词:{query, count, error?}
@@ -853,8 +862,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
base_url=intranet_url,
knowledge_base_name=ai_writing_config.get("intranet_knowledge_base", "samples"),
# 兜底接口超时可在 config.yaml 的 ai_writing.intranet_search_timeout 调大,
# 默认 600s(10 分钟),给内网慢检索留足时间。
timeout=ai_writing_config.get("intranet_search_timeout", 600),
# 默认 600s(10 分钟),且最低 300s。
timeout=_intranet_timeout_seconds(ai_writing_config),
)
intranet_holder["provider"] = intranet
fb_keywords = keywords[:keyword_count] or [intent]
@@ -1,6 +1,6 @@
from .base import BaseSearchProvider, SearchResult
from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider
from .intranet_stub import IntranetSearchProvider, normalize_intranet_timeout
from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider
from .skill_search import SkillSearchProvider
@@ -13,6 +13,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
search_provider: internet # internet(默认,DDG) / intranet / tavily / demo
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
intranet_knowledge_base: samples
intranet_search_timeout: 300 # 内网检索超时秒数,最少 300 秒
ddg_region: "cn-zh" # DuckDuckGo 区域,默认 cn-zh
internet 模式:DuckDuckGo 为主搜索(免 API Key),失败时 fallback 到 demo 演示素材。
@@ -24,6 +25,7 @@ def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
return IntranetSearchProvider(
base_url=cfg.get("intranet_search_url", ""),
knowledge_base_name=cfg.get("intranet_knowledge_base", "samples"),
timeout=normalize_intranet_timeout(cfg.get("intranet_search_timeout")),
)
if provider == "tavily":
return _FallbackSearchProvider(InternetSearchProvider(), DemoSearchProvider())
@@ -11,10 +11,22 @@ logger = logging.getLogger(__name__)
# 内网知识库默认检索接口与知识库名(可经 config.yaml 的 ai_writing 段覆盖)
_DEFAULT_ENDPOINT = "http://10.106.57.180:7863/knowledge_base/search_docs_es"
_DEFAULT_KB = "samples"
# 内网检索最少等待 5 分钟;该接口经常慢返回,低于这个值会造成素材收集误判为空。
_MIN_TIMEOUT_SECONDS = 300
_DEFAULT_TIMEOUT_SECONDS = 600
# 单条原始响应体最大保留长度,避免把超大响应整段灌进前端时间线
_RAW_SNIPPET_LIMIT = 2000
def normalize_intranet_timeout(timeout: object, default: int = _DEFAULT_TIMEOUT_SECONDS) -> int:
"""返回内网检索超时秒数,并强制不低于 5 分钟。"""
try:
value = int(timeout)
except (TypeError, ValueError):
value = default
return max(value, _MIN_TIMEOUT_SECONDS)
def _pick(d: dict, keys: tuple) -> str:
"""从结果项里按候选字段名取第一个非空值(两套接口字段名不同,做并集兼容)。"""
for k in keys:
@@ -38,17 +50,13 @@ class IntranetSearchProvider(BaseSearchProvider):
self,
base_url: str = "",
knowledge_base_name: str = _DEFAULT_KB,
timeout: int = 600,
timeout: int = _DEFAULT_TIMEOUT_SECONDS,
**_ignored,
):
self.endpoint = base_url or _DEFAULT_ENDPOINT
self.knowledge_base_name = knowledge_base_name or _DEFAULT_KB
# 内网 ES 接口很慢,兜底检索超时放宽到 10 分钟(不依赖 env 配置);构造方可传更大值覆盖。
# 「咱们先把自己的做好」:我方兜底超时给足,避免因我方过早超时被甩锅成「检不出」。
try:
self.timeout = max(int(timeout), 1)
except (TypeError, ValueError):
self.timeout = 600
# 内网 ES 接口很慢,兜底检索超时默认 10 分钟,且无论配置如何至少 5 分钟。
self.timeout = normalize_intranet_timeout(timeout)
# 收集本次运行的检索异常,供 researcher 节点取出并提示用户(ES 波动等)
self.errors: list[str] = []
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示