Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
commit
e30b44dae1
68 files changed
+10813
-3976
No files matched your search
@@ -30,8 +30,15 @@ const INPUT_CLASS =
|
||||
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
|
||||
const MAX_INTENT_LENGTH = 500;
|
||||
|
||||
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
|
||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: 'knowledge_base' | 'skill'; label: string }> = [
|
||||
{ value: 'knowledge_base', label: '知识库' },
|
||||
{ value: 'skill', label: '技能检索' },
|
||||
];
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
|
||||
const FIELD_TRIGGER_CLASS =
|
||||
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
|
||||
@@ -46,6 +53,8 @@ export function AIWritingForm({
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [materialSource, setMaterialSource] =
|
||||
useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
|
||||
const [modelName, setModelName] = useState('');
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
|
||||
@@ -134,6 +143,7 @@ export function AIWritingForm({
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
materialSource,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
noteSearchSpaceId: selectedNotebookId ?? undefined,
|
||||
// 有勾选文档则传 document_id 列表,否则传空数组(搜整个笔记本)
|
||||
@@ -204,6 +214,29 @@ export function AIWritingForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>素材来源</label>
|
||||
<Select
|
||||
value={materialSource}
|
||||
onValueChange={(v) => setMaterialSource(v as 'knowledge_base' | 'skill')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder="选择素材来源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-sm">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground/80">
|
||||
知识库:素材专家默认检索方式;技能检索:用已启用的技能去编排检索。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>写作模型</label>
|
||||
<Select
|
||||
|
||||
@@ -448,16 +448,24 @@ 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */}
|
||||
{state.status === 'researching' && state.skillSearchSteps.length > 0 && (
|
||||
{/* 技能检索 pending:用户点击瞬间到 awaiting 之间,始终显示 SkillStepsCard 占位
|
||||
steps.length === 0 时 SkillStepsCard 内部会渲染骨架行,不让用户干等空白 */}
|
||||
{state.skillSearchPending && (
|
||||
<div className="px-4 pb-2">
|
||||
<SkillStepsCard steps={state.skillSearchSteps} inProgress />
|
||||
</div>
|
||||
@@ -467,8 +475,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)) &&
|
||||
/* 技能检索占位卡 / 选择打字机 已经在流式展示,不再叠加 skeleton */
|
||||
!state.skillSearchPending &&
|
||||
!(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
|
||||
!state.intentStreamingText && !state.intentThinkingText && (
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function MaterialsToolbar({
|
||||
value={userQuery}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'}
|
||||
placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
|
||||
/>
|
||||
|
||||
@@ -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: '检索结果',
|
||||
@@ -30,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
|
||||
@@ -61,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
|
||||
@@ -116,9 +129,30 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{inProgress && (
|
||||
{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" />
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
更多步骤生成中…
|
||||
</li>
|
||||
)}
|
||||
|
||||
@@ -77,6 +77,7 @@ const INITIAL_STATE: AIWritingState = {
|
||||
skillSearchSteps: [],
|
||||
skillSelectStreamingText: '',
|
||||
skillSelectThinkingText: '',
|
||||
skillSearchPending: false,
|
||||
streamingReviewItems: [],
|
||||
revisionSummary: null,
|
||||
pendingRevisionContext: null,
|
||||
@@ -107,10 +108,13 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
|
||||
case 'intervention_submitted': {
|
||||
const ns = action.nextStatus;
|
||||
// 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载
|
||||
const enablingSkillSearch = action.record.action === 'enable_skill_search';
|
||||
return {
|
||||
...prev,
|
||||
currentPause: null,
|
||||
status: ns,
|
||||
skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending,
|
||||
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
|
||||
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
|
||||
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
@@ -343,7 +347,9 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
const extra: Partial<AIWritingState> = {};
|
||||
if (pp === 'material_confirm' && event.materials?.length) extra.materials = event.materials;
|
||||
if (pp === 'outline_confirm' && event.outline) extra.outline = event.outline;
|
||||
return { ...base, ...extra, status: pauseStatus, currentPause: event };
|
||||
// 进入素材确认(material_confirm)= 技能检索(如果在进行)已经完成,清掉 pending 占位
|
||||
const clearPending = pp === 'material_confirm' ? { skillSearchPending: false } : {};
|
||||
return { ...base, ...extra, ...clearPending, status: pauseStatus, currentPause: event };
|
||||
}
|
||||
|
||||
case 'done':
|
||||
@@ -630,12 +636,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__ 滤掉,
|
||||
@@ -743,6 +762,7 @@ export function useAIWriting() {
|
||||
word_count_target: request.wordCountTarget,
|
||||
keyword_count: Math.max(1, Math.min(10, request.keywordCount)),
|
||||
writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose',
|
||||
material_source: request.materialSource === 'skill' ? 'skill' : 'knowledge_base',
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
|
||||
@@ -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;
|
||||
@@ -227,10 +228,8 @@ export interface AIWritingRequest {
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
/** 笔记来源(可选):笔记本 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||
noteSourceIds?: number[];
|
||||
/** 素材来源:knowledge_base=知识库(默认检索)/ skill=技能检索(默认 knowledge_base) */
|
||||
materialSource: 'knowledge_base' | 'skill';
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
@@ -324,6 +323,9 @@ export interface AIWritingState {
|
||||
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
|
||||
skillSelectStreamingText: string;
|
||||
skillSelectThinkingText: string;
|
||||
/** 技能检索是否正在进行:用户点击"技能检索"瞬间 true,进入 awaiting 时清掉。
|
||||
* 用于在 LangGraph 还没推任何 skill_search_step 事件时给前端展示占位卡。 */
|
||||
skillSearchPending: boolean;
|
||||
// 流式审核(逐条到达)
|
||||
streamingReviewItems: StreamingReviewItem[];
|
||||
// 修改摘要
|
||||
|
||||
Reference in new issue
Block a user