Files
deerflow-code/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx
T
董宏凯andCursor 633a350bfc AI写作:技能检索进度反馈与超时兜底
检索等待时 SkillStepsCard 显示 spinner;单技能检索 25s 超时避免卡死;前后端增加 skill-timing 调试日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 14:42:51 +08:00

164 lines
7.3 KiB
TypeScript
Raw 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.
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition';
interface SkillStepsCardProps {
steps: SkillSearchStep[];
/** 是否仍在进行中(researching 状态下显示 spinner,让用户看到流式进展) */
inProgress?: boolean;
}
const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
list_skills: ListChecks,
match_skills: Search,
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
stop_early: CircleStop,
};
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
list_skills: '列出可用技能',
match_skills: '分析中',
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
stop_early: '提前停止',
};
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
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-violet-100/50 dark:hover:bg-violet-900/30 transition-colors text-left"
>
{inProgress
? <Loader2 className="size-3.5 text-violet-500 shrink-0 animate-spin" />
: <Sparkles className="size-3.5 text-violet-500 shrink-0" />}
<span className="flex-1">
技能检索过程
<span className="ml-1 text-muted-foreground font-normal">
(共 {steps.length} 步{inProgress ? ',进行中…' : ''})
</span>
</span>
<ChevronRight
className={cn(
'size-3 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90',
)}
/>
</button>
<CollapseTransition open={expanded}>
<ol className="px-3 pb-2.5 pt-1 space-y-1.5 border-t border-violet-200/60 dark:border-violet-900/30">
{steps.map((step, idx) => {
const Icon = STEP_ICON[step.type] ?? Sparkles;
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',
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',
)}
>
{isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
</span>
<div className="flex-1 min-w-0">
<div
className={cn(
'flex items-center gap-1 font-medium',
isStopEarly
? 'text-amber-700 dark:text-amber-300'
: isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)}
>
<Icon className="size-3 shrink-0" />
<span>{label}</span>
{step.skillName && (
<span className="font-mono text-violet-900 dark:text-violet-100">
· {step.skillName}
</span>
)}
</div>
<div className="text-muted-foreground mt-0.5">{step.message}</div>
{step.skillNames && step.skillNames.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{step.skillNames.map((name) => (
<span
key={name}
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200"
>
{name}
</span>
))}
</div>
)}
{step.reason && (
<div className="mt-0.5 text-[10px] text-violet-600/80 dark:text-violet-400/80 italic">
原因:{step.reason}
</div>
)}
</div>
</li>
);
})}
{inProgress && steps.length === 0 && (
// 用户刚点击"技能检索",还没收到任何步骤事件:用骨架行 + spinner 占位,
// 避免用户在等待后端 emit 时干等空白
<>
<li className="flex items-center gap-2 text-[11px] text-violet-700/80 dark:text-violet-300/70 italic">
<Loader2 className="size-3 animate-spin shrink-0" />
正在准备技能检索…
</li>
{[0, 1, 2].map((i) => (
<li key={`skel-${i}`} className="flex items-start gap-2 animate-pulse" style={{ animationDelay: `${i * 120}ms` }}>
<span className="shrink-0 mt-0.5 size-4 rounded-full bg-violet-100 dark:bg-violet-900/60" />
<div className="flex-1 min-w-0 space-y-1">
<div className="h-2.5 w-24 rounded bg-violet-100 dark:bg-violet-900/60" />
<div className="h-2 w-40 rounded bg-violet-50 dark:bg-violet-900/30" />
</div>
</li>
))}
</>
)}
{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" />
更多步骤生成中…
</li>
)}
</ol>
</CollapseTransition>
</div>
);
}