AI写作:技能检索进度反馈与超时兜底

检索等待时 SkillStepsCard 显示 spinner;单技能检索 25s 超时避免卡死;前后端增加 skill-timing 调试日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-05-31 14:42:51 +08:00
1 parent 23d6f9efc2
commit 633a350bfc
4 files changed
+65 -11

No files matched your search

@@ -32,6 +32,14 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const [expanded, setExpanded] = useState(true);
if (steps.length === 0 && !inProgress) return null;
// 进行中的步骤:最后一条是 invoke_skill 且还没出对应的 skill_result,
// 说明该技能的 web 检索正在 await 中——把它的序号徽标换成 spinner,
// 让每次(可能数秒~数十秒的)检索等待都有"正在调用技能X检索中"的实时反馈。
const activeIdx =
inProgress && steps.length > 0 && steps[steps.length - 1].type === 'invoke_skill'
? steps.length - 1
: -1;
return (
<div className="rounded-md border border-violet-200 bg-violet-50/40 dark:border-violet-900/40 dark:bg-violet-950/20 overflow-hidden">
<button
@@ -63,19 +71,22 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const label = STEP_LABEL[step.type] ?? step.type;
const isStopEarly = step.type === 'stop_early';
const isResult = step.type === 'skill_result';
const isActive = idx === activeIdx;
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
<span
className={cn(
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
isStopEarly
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
isActive
? 'bg-violet-500 text-white'
: isStopEarly
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: isResult
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
)}
>
{idx + 1}
{isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
</span>
<div className="flex-1 min-w-0">
<div
@@ -137,7 +148,9 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
))}
</>
)}
{inProgress && steps.length > 0 && (
{inProgress && steps.length > 0 && activeIdx < 0 && (
// 当前不在某个技能的检索等待中(最后一条是 result / select 等),
// 提示还有后续步骤;正在检索时由该步自身的 spinner 体现进行中,不重复。
<li className="flex items-center gap-2 text-[11px] text-muted-foreground italic">
<Loader2 className="size-3 animate-spin shrink-0" />
更多步骤生成中…
@@ -634,12 +634,25 @@ export function useAIWriting() {
dispatch({ type: 'set_session', sessionId: meta.thread_id });
},
onCustomEvent(event: unknown) {
// 技能检索时机 trace:开 window.__AI_WRITING_DEBUG__ = true 后,控制台按
// performance.now() 打印每条 custom 事件到达时刻 + type,用来钉死
// skill_search_step(技能检索过程卡)与 skill_select_chunk(筛选技能打字)
// 的实际到达顺序,区分"后端未实时 emit" vs "前端渲染滞后"。
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
const t = (event as { type?: string } | null)?.type;
// eslint-disable-next-line no-console
console.debug(`[ai-writing:custom] +${Math.round(performance.now())}ms type=${t ?? '?'}`, event);
}
const pe = customToProgressEvent(event, state.sessionId ?? '');
if (pe) dispatch({ type: 'handle', event: pe });
},
onUpdateEvent(data: Record<string, unknown> | undefined) {
// data 形如 { node_name: { ...node_output } },可能多个节点同一 step
if (!data) return;
if (typeof window !== 'undefined' && (window as { __AI_WRITING_DEBUG__?: boolean }).__AI_WRITING_DEBUG__) {
// eslint-disable-next-line no-console
console.debug(`[ai-writing:updates] +${Math.round(performance.now())}ms nodes=${Object.keys(data).join(',')}`);
}
// 兜底:updates 通道可能直接带 ``__interrupt__`` 数组(节点进 interrupt 时
// LangGraph 推 ``{__interrupt__: [...]}``),SDK 主路径只在 values 通道
// 暴露 stream.interrupt —— 万一后端 serialize 又把 __interrupt__ 滤掉,
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import logging
import re
import time
import uuid
from typing import List
@@ -29,6 +30,11 @@ _MAX_MAX_MATERIALS = 100
_DEFAULT_KEYWORD_COUNT = 6
_MAX_KEYWORD_COUNT = 10
# 单个技能的 web 检索超时(秒)。技能检索是顺序循环、最多 5 个技能,
# DuckDuckGo 等 provider 偶发限速/挂起会把整段"技能检索过程"拖到几十秒看起来卡死;
# 超时即放弃该技能、emit 0 条结果继续下一个,保证时间线持续推进。
_SKILL_SEARCH_TIMEOUT_SECONDS = 25
def _resolve_keyword_count(raw: object) -> int:
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
@@ -178,6 +184,10 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
def _emit_skill_step(step: dict) -> None:
skill_steps.append(step)
# 技能检索时机 trace:grep "[skill-timing]" 看每步 emit 的真实时刻,
# 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。
logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s",
time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
if is_skill_search:
@@ -205,15 +215,25 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
for entry in selected:
sk = entry["skill"]
# 用 LLM 把 user_query + skill.description 转成针对性关键词
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
# invoke_skill 步骤带上实际检索词(截断防过长),让用户在每次长 await 之前
# 就看到"这一步在用技能 X 检索什么",配合前端 spinner 体现进行中。
_emit_skill_step({
"type": "invoke_skill",
"message": f"使用技能「{sk.name}」开始检索…",
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name,
})
skill_query = f"{user_query} {sk.description or ''}".strip() or intent
try:
res = await web_provider.search(skill_query, max_results=per_skill)
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
res = await asyncio.wait_for(
web_provider.search(skill_query, max_results=per_skill),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning("skill[%s] search timed out after %ss", sk.name, _SKILL_SEARCH_TIMEOUT_SECONDS)
search_errors.append(f"skill[{sk.name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
res = []
except Exception as e:
logger.warning("skill[%s] search failed: %s", sk.name, e)
search_errors.append(f"skill[{sk.name}]: {e}")
@@ -16,6 +16,7 @@
from __future__ import annotations
import logging
import time
from typing import Callable, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
@@ -140,7 +141,14 @@ class SkillPicker:
parser = ThinkTagParser()
full_text = ""
_chunk_count = {"n": 0}
def _emit_chunk(text: str, is_thinking: bool) -> None:
# 只在首个 chunk 打一条 timing,避免逐 token 刷屏;用来和 skill_search_step
# 的 [skill-timing] 对比"选择打字"与"步骤事件"谁先到。
if _chunk_count["n"] == 0:
logger.info("[skill-timing] t=%.3f first skill_select_chunk", time.monotonic())
_chunk_count["n"] += 1
writer({
"type": "skill_select_chunk",
"text": text,