feat(ai写作): 左栏智能体 Tab 内嵌问答并优化模块侧栏
AI 写作页默认展示智能体 Tab,在页内复用 ai-writing 模块配置与 AgentChatPage;确认类型后侧栏扁平展示类型名并支持整块收起动画。同步历史回看、流式续跑、改写菜单与 researcher 检索相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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)排查内网接口到底通不通。生产建议关掉(会多跑一次内网)。
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+14
-5
@@ -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]
|
||||
|
||||
+3
-1
@@ -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())
|
||||
|
||||
+15
-7
@@ -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] = []
|
||||
# 解析失败时收集的原始响应体,供前端「错误返回结果」折叠展示
|
||||
|
||||
Reference in new issue
Block a user