代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-29 18:07:45 +08:00
1 parent 8ed3b0eb91
commit 786ddaa38f
9 files changed
+293 -173

No files matched your search

@@ -448,8 +448,15 @@ export function AIWritingPanel({
/>
)}
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空 */}
{state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && (
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空。
挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载,
让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */}
{state.status === 'researching' && (
state.skillSelectStreamingText
|| state.skillSelectThinkingText
|| (state.skillSearchSteps.some((s) => s.type === 'match_skills')
&& !state.skillSearchSteps.some((s) => s.type === 'select_skills'))
) && (
<SkillSelectStreamingView
text={state.skillSelectStreamingText}
thinkingText={state.skillSelectThinkingText}
@@ -467,8 +474,9 @@ export function AIWritingPanel({
state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */
!(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
/* 技能检索已经在流式展示步骤 / 选择打字机 / 等待 chunk 中,不再叠加 skeleton */
!(state.status === 'researching' && state.skillSearchSteps.length > 0) &&
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
@@ -7,25 +7,22 @@ interface SkillSelectStreamingViewProps {
}
/**
* 从流式 JSON 文本里增量提取已经识别出的技能名。
* 从流式 JSON 文本里增量提取已识别的技能名(仅取**已闭合**的字符串项)。
*
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`,
* 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串:
* - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容
* - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."`
* - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`。
* 不等整段 JSON 解析完,逐字符扫描:
* - 定位 `"selected_skills":\s*[`
* - 在该位置后到 `]`(或文本末尾)之间逐个匹配 `"..."`
* - 只取已闭合的字符串(结尾必须是 `"`),保证不显示正在打字的半截名字
*/
function extractStreamingSkillNames(text: string): string[] {
if (!text) return [];
// 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白)
const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/);
if (!keyMatch) return [];
const start = keyMatch.index! + keyMatch[0].length;
// 截取数组段(找到对应的 ],或者文本结尾)
const rest = text.slice(start);
const endIdx = rest.indexOf(']');
const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest;
// 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号
const names: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let m: RegExpExecArray | null;
@@ -37,32 +34,46 @@ function extractStreamingSkillNames(text: string): string[] {
}
/**
* 技能选择 LLM 流式打字机视图。
* 从流式 JSON 文本里提取 `reason` 字段当前已输出的内容。
*
* - 思考过程(<think></think> 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致)
* - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义)
* - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标
* 和 skill name 不同,reason 是流式叙述文本,**未闭合也要展示**(带闪烁光标),
* 让用户看到 LLM 正在解释选择理由。
*/
function extractStreamingReason(text: string): { value: string; closed: boolean } {
if (!text) return { value: '', closed: false };
// 匹配 "reason": "..." 直到下一个未转义的 " 或文本末尾
const m = text.match(/["']?reason["']?\s*:\s*"((?:[^"\\]|\\.)*?)("|$)/);
if (!m) return { value: '', closed: false };
const raw = m[1];
const closed = m[2] === '"';
// 反转义常见序列
const value = raw
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
return { value, closed };
}
export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
// 实时从流式 JSON 中增量解析出已识别的技能名
// 增量解析:候选技能 + 选择原因
const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]);
useEffect(() => {
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
}, [text]);
const reason = useMemo(() => extractStreamingReason(text), [text]);
useEffect(() => {
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
}, [thinkingText, thinkOpen]);
if (!text && !thinkingText) return null;
// 步骤预览始终渲染(挂载即显示),让父组件控制挂载时机;
// 这样首个空 chunk 到达时用户就能看到"等待模型输出"占位 + 跳动光标
const showStepPreview = true;
return (
<div className="px-4 pb-2 space-y-2">
{/* 思考过程(amber,可折叠) */}
{thinkingText && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
<button
@@ -94,15 +105,16 @@ export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStre
</div>
)}
{text && (
{/* 步骤预览(violet,与 SkillStepsCard 的"筛选相关技能"步骤同构) */}
{showStepPreview && (
<div className="rounded-xl border border-violet-200 dark:border-violet-900 bg-violet-50/50 dark:bg-violet-950/20 overflow-hidden shadow-sm">
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-200/60 dark:border-violet-900/60 bg-violet-100/40 dark:bg-violet-900/30">
<Sparkles className="size-3.5 text-violet-500 shrink-0" />
<span className="text-xs font-medium text-violet-600 dark:text-violet-400">
正在筛选相关技能
筛选相关技能
{streamingSkills.length > 0 && (
<span className="ml-1 text-violet-500/80">
· 已识别 {streamingSkills.length} 个
<span className="ml-1 text-violet-500/80 font-normal">
· 候选 {streamingSkills.length} 个(将按顺序使用)
</span>
)}
</span>
@@ -117,29 +129,42 @@ export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStre
</span>
</div>
{/* 已识别的技能名:每个新出现的 badge 会 fade-in,给用户即时反馈 */}
{streamingSkills.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-3 py-2 border-b border-violet-200/40 dark:border-violet-900/40 bg-violet-50/40 dark:bg-violet-950/10">
{streamingSkills.map((name, idx) => (
<span
key={`${name}-${idx}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
style={{ animationFillMode: 'both' }}
>
<Zap className="size-2.5 shrink-0" />
{name}
</span>
))}
</div>
)}
<div className="px-3 py-2.5 space-y-2">
{/* 已识别的候选技能 badges */}
{streamingSkills.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{streamingSkills.map((name, idx) => (
<span
key={`${name}-${idx}`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
style={{ animationFillMode: 'both' }}
>
<Zap className="size-2.5 shrink-0" />
{name}
</span>
))}
</div>
)}
<pre
ref={preRef}
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
>
{text}
<span className="inline-block w-0.5 h-3.5 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</pre>
{/* reason 实时文本:未闭合也展示(带光标),闭合后光标消失 */}
{reason.value && (
<div className="text-[11px] text-violet-700/85 dark:text-violet-300/85 italic leading-relaxed whitespace-pre-wrap">
<span className="not-italic font-medium text-violet-600 dark:text-violet-400 mr-1">原因:</span>
{reason.value}
{!reason.closed && (
<span className="inline-block w-0.5 h-3 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
)}
</div>
)}
{/* 还没出现任何 skill / reason 时给一个占位提示 */}
{!streamingSkills.length && !reason.value && (
<div className="text-[11px] text-muted-foreground italic flex items-center gap-1">
等待模型输出
<span className="inline-block w-0.5 h-3 bg-violet-400 align-text-bottom animate-[blink_1s_step-end_infinite]" />
</div>
)}
</div>
</div>
)}
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop } from 'lucide-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';
@@ -12,6 +12,7 @@ interface SkillStepsCardProps {
const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
list_skills: ListChecks,
match_skills: Search,
select_skills: Sparkles,
invoke_skill: Zap,
skill_result: CheckCircle2,
@@ -20,6 +21,7 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
list_skills: '列出可用技能',
match_skills: '分析中',
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
skill_result: '检索结果',
@@ -16,12 +16,13 @@ export interface AIMaterial {
// 技能检索过程的单步记录(前端时间线展示"使用了 X 技能")
// list_skills:列出全部 enabled 技能
// match_skills:进入 LLM 流式选择前的过渡提示(让用户立刻看到反馈)
// select_skills:LLM 按相关度排序的候选
// invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材
// stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep {
type: 'list_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
type: 'list_skills' | 'match_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
message: string;
skillNames?: string[];
skillName?: string;