检索等待时 SkillStepsCard 显示 spinner;单技能检索 25s 超时避免卡死;前后端增加 skill-timing 调试日志。 Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
7.3 KiB
TypeScript
164 lines
7.3 KiB
TypeScript
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>
|
||
);
|
||
}
|