代码修改

This commit is contained in:
fengxiangdebaozi committed 2026-05-29 11:19:58 +08:00
1 parent de597b602a
commit 1a122eb7fd
20 files changed
+812 -140

No files matched your search

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><g transform="translate(-128.5 -117)"><rect class="a" width="16" height="16" transform="translate(128.5 117)"/><path class="b" d="M4,0,8,5H0Z" transform="translate(140.5 128) rotate(180)"/></g></svg>

After

Width:  |  Height:  |  Size: 589 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="84.499" height="70" viewBox="0 0 84.499 70"><defs><style>.a{fill:url(#a);}</style><linearGradient id="a" x1="0.043" x2="0.978" y2="1.016" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0062d9" stop-opacity="0.6"/><stop offset="1" stop-color="#0062d9" stop-opacity="0.302"/></linearGradient></defs><path class="a" d="M8855.277-1940.39l-.054-70h78v31.183l6.5,5.257-6.5,5.259v28.3Z" transform="translate(-8855.224 2010.389)"/></svg>

After

Width:  |  Height:  |  Size: 536 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="77" height="45" viewBox="0 0 77 45"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 340 45"><image width="340" height="45" xlink:href="data:image/png;base64,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 truncated

After

Width:  |  Height:  |  Size: 12 KiB

+43 -1
View File
@@ -10,11 +10,12 @@
flex-direction: column;
align-items: center;
padding: 12px 10px;
position: relative;
}
.item-nav{
width:100%;
padding:14px 0;
padding:10px 0;
border-bottom:1px solid rgba(85, 166, 254, 0.2);
font-family:'Noto Sans S Chinese', sans-serif;
font-weight:500;
@@ -39,4 +40,45 @@
box-sizing:border-box;
letter-spacing:6px;
word-break:break-all;
}
.item-nav-content{
width: 155px;
height: 970px;
background: rgba(3, 38, 80, 0.8);
border: 1px solid;
border-color: rgba(43, 143, 254, 0.6);
border-radius: 4px;
backdrop-filter: blur(2px);
position: absolute;
left: 97px;
top: 0;
z-index: 1000;
}
.item-nav-active{
color:#ffffff;
position: relative;
}
.item-nav-active::before{
content: '';
position: absolute;
left: 0;
top: 0;
width:84.5px;
height:70px;
background: url('../assets/icon/nav-active.svg') no-repeat;
background-size: contain;
}
.item-nav-active::after{
content: '';
position: absolute;
left: 0;
top: 25px;
width:77px;
height:45px;
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
background-size: cover;
}
@@ -332,8 +332,9 @@ export function PageSidebarDark({
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
<div className="sidebar-contnet">
<div className="sidebar-contents">
<div className="item-nav-content"></div>
{visibleMenuItems.map((item) => (
<div key={item.id} className="item-nav">
<div key={item.id} className={`item-nav ${item.id === 'qa-center' ? 'item-nav-active' : ''}`}>
<span className="item-nav__label">{item.label}</span>
</div>
))}
@@ -38,6 +38,7 @@ const ENABLE_PAUSE_RESUME_UI = false;
import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView';
import { IntentResultCard } from './IntentResultCard';
@@ -411,6 +412,8 @@ export function AIWritingPanel({
reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
onSubmit={submitIntervention}
disabled={false}
/>
@@ -444,10 +447,19 @@ export function AIWritingPanel({
/>
)}
{/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */}
{state.status === 'researching' && state.skillSearchSteps.length > 0 && (
<div className="px-4 pb-2">
<SkillStepsCard steps={state.skillSearchSteps} inProgress />
</div>
)}
{showSkeleton &&
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) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen,
Trash2, BookOpen, Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -10,11 +10,13 @@ import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload,
BlockedSection, SectionDecisionKind,
SkillSearchStep,
} from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard';
import { SkillStepsCard } from './SkillStepsCard';
interface InterventionCardProps {
pauseEvent: ProgressEvent;
@@ -23,18 +25,29 @@ interface InterventionCardProps {
reviewResult: ReviewResult | null;
draftTitle: string;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, onSubmit, disabled,
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') {
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
return (
<MaterialConfirmCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
/>
);
}
if (pausePoint === 'outline_confirm') {
@@ -106,30 +119,40 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
interface MaterialConfirmCardProps {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
const [userQuery, setUserQuery] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
async function handleReSearch() {
setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasExtra = extraKeywords.length > 0;
async function handleEnableSkillSearch() {
setSubmitting(true);
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
setSubmitting(false);
}
const hasQuery = userQuery.trim().length > 0;
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
if (materials.length === 0) {
@@ -140,33 +163,45 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
</p>
</div>
</div>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set())}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
userQuery={userQuery}
onUserQueryChange={setUserQuery}
onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/>
<div className="flex gap-2 pt-1">
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleConfirm} disabled={disabled || submitting}
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
>
<ArrowRight className="size-3.5" />无数据继续写作
</Button>
@@ -182,11 +217,14 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
<div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
</p>
</div>
</div>
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
setSelectedIds((prev) => {
const next = new Set(prev);
@@ -200,24 +238,34 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
userQuery={userQuery}
onUserQueryChange={setUserQuery}
onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/>
<div className="flex gap-2 pt-1">
<div className="flex flex-wrap gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
>
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button>
{!isSkillResult && (
<Button
type="button" variant="outline" size="sm"
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
>
<Sparkles className="size-3.5" />技能检索
</Button>
)}
<Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting}
>
{hasExtra
? <><Search className="size-3.5" />追加搜索</>
{hasQuery
? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>}
</Button>
</div>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronRight, ExternalLink } from 'lucide-react';
import { ChevronRight, ExternalLink, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import { buildIntranetSourceUrl } from '@/core/config';
import type { AIMaterial } from '../../types/ai-writing';
@@ -26,12 +26,14 @@ function relevanceLabel(score: number): string {
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
const [expanded, setExpanded] = useState(false);
const pct = Math.round(material.relevanceScore * 100);
const isSkill = material.sourceType === 'skill';
return (
<div
className={cn(
'rounded-md border text-xs transition-colors',
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
isSkill && checked && 'border-violet-300/70 dark:border-violet-800/60',
)}
>
{/* 头部行 */}
@@ -50,6 +52,12 @@ export function MaterialItem({ material, checked, onToggle }: MaterialItemProps)
onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-start gap-1.5 text-left"
>
{isSkill && (
<span className="mt-0.5 inline-flex items-center gap-0.5 px-1 py-0.5 rounded text-[10px] font-medium bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200 shrink-0">
<Sparkles className="size-2.5" />
技能
</span>
)}
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
{material.title}
</span>
@@ -1,6 +1,4 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useRef } from 'react';
import type { AIMaterial } from '../../types/ai-writing';
interface MaterialsToolbarProps {
@@ -8,9 +6,13 @@ interface MaterialsToolbarProps {
selectedIds: Set<string>;
onSelectAll: () => void;
onDeselectAll: () => void;
extraKeywords: string[];
onAddKeyword: (kw: string) => void;
onRemoveKeyword: (kw: string) => void;
/** 单输入框文本:可以是技能名、检索来源、补充检索意图 */
userQuery: string;
onUserQueryChange: (text: string) => void;
/** 回车键提交(默认重新搜索;详细按钮交给父组件渲染) */
onSubmitQuery?: () => void;
placeholder?: string;
disabled?: boolean;
}
export function MaterialsToolbar({
@@ -18,18 +20,27 @@ export function MaterialsToolbar({
selectedIds,
onSelectAll,
onDeselectAll,
extraKeywords,
onAddKeyword,
onRemoveKeyword,
userQuery,
onUserQueryChange,
onSubmitQuery,
placeholder,
disabled,
}: MaterialsToolbarProps) {
const [kwInput, setKwInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
function handleAddKeyword() {
const kw = kwInput.trim();
if (!kw || extraKeywords.includes(kw)) return;
onAddKeyword(kw);
setKwInput('');
function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
onUserQueryChange(e.target.value);
const el = e.target;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey && onSubmitQuery) {
e.preventDefault();
onSubmitQuery();
}
}
return (
@@ -51,50 +62,21 @@ export function MaterialsToolbar({
</div>
)}
{/* 补充关键词 */}
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
<div className="flex gap-1.5">
<input
type="text"
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
placeholder="输入关键词后按 Enter 或点击 +"
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddKeyword}
disabled={!kwInput.trim()}
>
<Plus className="size-3" />
</Button>
</div>
{/* 已添加关键词 Tag 列表 */}
{extraKeywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{extraKeywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
onClick={() => onRemoveKeyword(kw)}
className="hover:text-destructive leading-none"
>
×
</button>
</span>
))}
</div>
)}
<label className="text-xs text-muted-foreground">
补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
</label>
<textarea
ref={textareaRef}
rows={1}
value={userQuery}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'}
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"
/>
</div>
</div>
);
@@ -0,0 +1,105 @@
import { useState } from 'react';
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2 } 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,
select_skills: Sparkles,
invoke_skill: Zap,
};
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
list_skills: '列出可用技能',
select_skills: '筛选相关技能',
invoke_skill: '调用技能',
};
export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const [expanded, setExpanded] = useState(true);
if (steps.length === 0 && !inProgress) return null;
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;
return (
<li key={idx} className="flex items-start gap-2 text-[11px]">
<span className="shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300">
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 font-medium 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 && (
<li className="flex items-center gap-2 text-[11px] text-muted-foreground italic">
<Loader2 className="size-3 animate-spin" />
更多步骤生成中…
</li>
)}
</ol>
</CollapseTransition>
</div>
);
}
@@ -41,6 +41,7 @@ import type {
ProgressEventType,
ReviewIssue,
ReviewResult,
SkillSearchStep,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
@@ -71,6 +72,7 @@ const INITIAL_STATE: AIWritingState = {
intentThinkingText: '',
parsedIntent: null,
researchProgress: '',
skillSearchSteps: [],
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
@@ -105,7 +107,7 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
...prev,
currentPause: null,
status: ns,
...(ns === 'researching' ? { researchProgress: '' } : {}),
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [] } : {}),
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
@@ -191,12 +193,22 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
case 'skill_search_step': {
const step = event.skillStep;
if (!step) return base;
return { ...base, skillSearchSteps: [...prev.skillSearchSteps, step] };
}
case 'materials_ready': {
// 后端把 pkg.skill_search_steps 一并塞回来时,覆盖前端累积(保证刷新页面回放也对)
const incomingSteps = (event as unknown as { skillSearchSteps?: SkillSearchStep[] }).skillSearchSteps;
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
};
}
case 'outline_chunk': {
const text = (event as unknown as { text: string }).text ?? '';
@@ -337,6 +349,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
// ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
const extra = (m.extra_metadata ?? m.extraMetadata) as Record<string, unknown> | undefined;
return {
id: String(m.id ?? ''),
title: String(m.title ?? ''),
@@ -344,9 +357,21 @@ function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
source: String(m.source ?? ''),
publishedAt: String(m.published_at ?? m.publishedAt ?? ''),
relevanceScore: Number(m.relevance_score ?? m.relevanceScore ?? 0),
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as 'internet' | 'intranet',
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as AIMaterial['sourceType'],
url: m.url as string | undefined,
recUuid: (m.recUuid ?? m.rec_uuid) as string | undefined,
extraMetadata: extra,
};
}
function normalizeSkillStep(s: Record<string, unknown>): SkillSearchStep {
return {
type: (s.type as SkillSearchStep['type']) ?? 'invoke_skill',
message: String(s.message ?? ''),
skillNames: (s.skill_names ?? s.skillNames) as string[] | undefined,
skillName: (s.skill_name ?? s.skillName) as string | undefined,
reason: s.reason as string | undefined,
matchedCount: (s.matched_count ?? s.matchedCount) as number | undefined,
};
}
@@ -445,6 +470,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
'intent_chunk', 'intent_ready',
'research_progress', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done',
'skill_search_step',
];
if (!allowed.includes(type as ProgressEventType)) return null;
@@ -459,6 +485,10 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
if (Array.isArray(merged.issues)) {
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
}
// skill_search_step:把字段折成 skillStep 子对象,方便 reducer / UI 取用
if (type === 'skill_search_step') {
merged.skillStep = normalizeSkillStep(o);
}
return merged as unknown as ProgressEvent;
}
@@ -478,6 +508,9 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
// ``material_package`` 出现 → materials_ready(节点 progress_events 里不带)
if (out.material_package && typeof out.material_package === 'object') {
const pkg = out.material_package as Record<string, unknown>;
const skillSteps = Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps)
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep)
: undefined;
events.push({
type: 'materials_ready',
sessionId,
@@ -485,7 +518,8 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
materials: Array.isArray(pkg.materials) ? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [],
keywords: (pkg.keywords ?? []) as string[],
materialSummary: (pkg.summary as string) ?? '',
});
...(skillSteps ? { skillSearchSteps: skillSteps } as unknown as Partial<ProgressEvent> : {}),
} as ProgressEvent);
}
// ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条
@@ -7,9 +7,24 @@ export interface AIMaterial {
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet';
sourceType: 'internet' | 'intranet' | 'skill';
url?: string;
recUuid?: string;
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
extraMetadata?: Record<string, unknown>;
}
// 技能检索过程的单步记录(前端时间线展示"使用了 X 技能")
// list_skills:列出全部 enabled 技能
// select_skills:LLM 按相关度排序的候选
// invoke_skill:实际按顺序调用某个技能
export interface SkillSearchStep {
type: 'list_skills' | 'select_skills' | 'invoke_skill';
message: string;
skillNames?: string[];
skillName?: string;
reason?: string;
matchedCount?: number;
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
@@ -139,6 +154,7 @@ export type ProgressEventType =
| 'await_user'
| 'resumed'
| 'notice'
| 'skill_search_step'
| 'done'
| 'error';
@@ -173,6 +189,8 @@ export interface ProgressEvent {
pauseContext?: Record<string, unknown>;
// section_help:素材不足、待用户决策的章节
blockedSections?: BlockedSection[];
// skill_search_step:技能检索步骤事件(list_skills / select_skills / invoke_skill)
skillStep?: SkillSearchStep;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
@@ -180,7 +198,10 @@ export interface ProgressEvent {
export interface UserInterventionPayload {
action: string;
approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
userQuery?: string;
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
@@ -291,6 +312,8 @@ export interface AIWritingState {
parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
skillSearchSteps: SkillSearchStep[];
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
@@ -36,7 +36,9 @@ import {
type RecommendCandidate,
type RecommendStreamPhase,
} from "../api/recommend";
import { useAutoScroll } from "../hooks/useAutoScroll";
import { splitInlineReasoning } from "../lib/reasoning";
import { ScrollToBottomButton } from "./ScrollToBottomButton";
/**
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
@@ -453,6 +455,7 @@ export function RecommendAgentsDialog({
state.phase === "loading_agents" || state.phase === "recommending"
}
phase={state.phase}
scrollDeps={[state.rationale, state.steps, state.streamPhase]}
>
<RecommenderOutput state={state} />
</CollapsibleModelOutput>
@@ -593,16 +596,22 @@ const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
function CollapsibleModelOutput({
isLoading,
phase,
scrollDeps,
children,
}: {
/** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */
isLoading: boolean;
phase: DialogPhase;
/** 内容变化时触发自动滚到底(与 Step1/Step2 对话区同一套 useAutoScroll)。 */
scrollDeps: React.DependencyList;
children: React.ReactNode;
}) {
const [open, setOpen] = useState<boolean>(true);
const userTouchedRef = useRef<boolean>(false);
const hasAutoClosedRef = useRef<boolean>(false);
const modelOutputScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: modelOutputAtBottom, scrollToBottom: scrollModelOutputToBottom } =
useAutoScroll(modelOutputScrollRef, scrollDeps);
// 加载中 → 保持展开(用户没手动动过的前提下)。
// 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。
@@ -677,8 +686,16 @@ function CollapsibleModelOutput({
/>
</button>
{open && (
<div className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
{children}
<div className="relative min-h-0 flex-1 flex flex-col">
<div
ref={modelOutputScrollRef}
className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar"
>
{children}
</div>
{!modelOutputAtBottom && (
<ScrollToBottomButton onClick={scrollModelOutputToBottom} />
)}
</div>
)}
</section>
@@ -751,7 +768,10 @@ function RecommenderOutput({ state }: { state: InternalState }) {
{hasReasoning && (
<ReasoningBlock
streaming={isStreaming}
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
contentClassName={cn(
"overflow-y-auto custom-scrollbar",
isStreaming ? "max-h-none" : "max-h-48",
)}
>
{reasoning!}
</ReasoningBlock>
@@ -762,7 +782,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
)}
{hasRationale && (
<div className="max-h-40 overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
<div
className={cn(
"overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed",
isStreaming ? "max-h-none" : "max-h-40",
)}
>
<MarkdownContent>{cleanedRationale}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
+19 -1
View File
@@ -23,6 +23,19 @@ models:
supports_vision: true
supports_thinking: true
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-1520028668cb4cc388ed8315ff1e856e
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-v4-flash
display_name: DeepSeek V4 Flash
use: langchain_openai:ChatOpenAI
@@ -86,6 +99,11 @@ tools:
use: deerflow.community.ddg_search.tools:web_search_tool
max_results: 5
- name: web_fetch
group: web
use: deerflow.community.jina_ai.tools:web_fetch_tool
timeout: 15
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
@@ -121,7 +139,7 @@ sandbox:
allow_host_bash: true
skills:
path: D:\cursor-work\offline-backend-20260512\backend\skills
path: skills
security_scan:
enabled: false
@@ -0,0 +1,11 @@
{
"mcpServers": {},
"skills": {
"web-research": { "enabled": true },
"deep-search": { "enabled": true },
"code-retrieval": { "enabled": true },
"news-briefing": { "enabled": true },
"workspace-search": { "enabled": true },
"policy-knowledge": { "enabled": true }
}
}
@@ -55,6 +55,9 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
action = user_input.get("action", "confirm")
# 单输入框文本:可能是技能名 / 来源 / 检索意图,re_search 和 enable_skill_search 都用得到
user_query = (user_input.get("userQuery") or user_input.get("user_query") or "").strip()
if action == "confirm":
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
@@ -66,12 +69,22 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "用户已确认素材,开始规划大纲"}],
}
# re_search:重新搜索
# enable_skill_search:用户选择"启用技能检索",回到 researcher 走 skill 模式
if action == "enable_skill_search":
msg = f"已启用技能检索(输入:{user_query[:30]})" if user_query else "已启用技能检索,正在筛选可用技能…"
return {
"last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query},
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
# re_search:重新搜索(支持新版 user_query 单输入框 + 旧版 extraKeywords 数组)
extra_kws = user_input.get("extraKeywords") or []
msg = f"按输入「{user_query[:30]}」重新搜索…" if user_query else "重新搜索中..."
return {
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws},
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query},
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "重新搜索中..."}],
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
@@ -9,7 +9,7 @@ from typing import List
from langchain_core.runnables import RunnableConfig
from ..search import get_search_provider, SearchResult
from ..search import get_search_provider, SearchResult, SkillSearchProvider
from ..state import AIWritingState, Material, MaterialPackage
from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
@@ -96,15 +96,24 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 检查干预模式(PAUSE-1 素材确认 / 素材不足求助)
intervention = state.get("last_user_intervention") or {}
extra_keywords: list[str] = intervention.get("_extra_keywords") or intervention.get("extra_keywords") or []
# PAUSE-1 单输入框:用户写的自然语言(技能名 / 来源 / 检索意图),优先级最高
user_query: str = (intervention.get("user_query") or "").strip()
action = intervention.get("action")
# 素材不足求助点触发的「补充检索」:保留原大纲,检索完直接回写作、不走素材确认/大纲
is_supplement = action == "supplement_search"
# PAUSE-1 素材确认卡用户点「追加检索」并填了新关键词:
# PAUSE-1 素材确认卡用户点「按输入重新搜索」并填了 user_query:
# - 跳过 LLM 重新生成检索词(节省一次调用 + 尊重用户意图)
# - 只检索 extra_keywords 这几个新词,不重检索之前 LLM 出过的词
# - 直接用 user_query 作为唯一检索词
# - 保留 existing material_package,按 URL 去重合并
is_append_only = action == "re_search" and bool(extra_keywords)
is_user_query_search = action == "re_search" and bool(user_query)
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
# PAUSE-1 素材确认卡用户点「启用技能检索」:
# - 不再走 web/intranet 检索,改用 SkillSearchProvider
# - 顺序调用候选技能 + 早停(第一个技能够就停,不够再加下一个)
# - 旧 material_package 完全丢弃(覆盖式替换,让用户对比 web vs skill 结果)
is_skill_search = action == "enable_skill_search"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -126,10 +135,14 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
writer({"type": "research_progress", "message": message})
# Step 1: 提取关键词
kw_result: dict = {} # 走追加分支时跳过 LLM 调用,但下方 notice 拼接需要兜底值
if is_append_only:
# 追加模式:直接用用户填的关键词,不再调 LLM
keywords: list[str] = _clean_keywords(extra_keywords, intent)
kw_result: dict = {} # 走"用户输入 / 追加关键词"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_user_query_search:
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
keywords: list[str] = [user_query]
_emit(f"按你的输入「{user_query[:30]}」检索…")
elif is_append_only:
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
keywords = _clean_keywords(extra_keywords, intent)
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
@@ -150,13 +163,31 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
if extra_keywords:
keywords = list(dict.fromkeys(keywords + extra_keywords))
# Step 2: 并发搜索
provider = get_search_provider(ai_writing_config)
# Step 2: 搜索
# 技能检索:用 SkillSearchProvider 替代 web/intranet;步骤事件单独走 skill_search_step 通道
skill_steps: list[dict] = []
if is_skill_search:
def _emit_skill_step(step: dict) -> None:
skill_steps.append(step)
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
async def _llm_select(system: str, user_prompt: str, fallback: dict) -> dict:
return await llm_json(system, user_prompt, fallback=fallback, config=config, model_name=keyword_model)
provider = SkillSearchProvider(
llm_select=_llm_select,
emit_step=_emit_skill_step,
max_select=max(1, min(int(max_materials), 10)),
user_query=user_query,
)
else:
provider = get_search_provider(ai_writing_config)
# 按用户指定的关键词数量截断。
# 追加模式:keywords 本身就是用户填的,全部检索不截断(用户填 N 个就搜 N 个)。
# 用户输入 / 追加模式:keywords 本身就是用户填的,全部检索不截断。
# 其他模式:受 keyword_count 限制,但 extra_keywords 在内时上限放宽到 keywords 总数。
if is_append_only:
if is_user_query_search or is_append_only:
search_keywords = keywords
else:
search_limit = keyword_count
@@ -168,31 +199,48 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
per_keyword = _per_keyword_limit(max_materials, total)
_emit(f"检索词已生成,开始检索 {total} 个方向…")
# 并发搜索,但每完成一个就推送进度(计数在单线程 asyncio 下无需加锁)
completed = 0
# 技能模式:串行调用 provider(早停语义靠 provider 内部累积计数)
# 其它模式:并发调用,每完成一个就推送进度
if is_skill_search:
all_results_nested: list[list[SearchResult]] = []
for kw in search_keywords:
try:
res = await provider.search(kw, max_results=per_keyword, user_intent=intent)
except Exception as e:
logger.warning("skill search failed: %s", e)
all_results_nested.append(e) # type: ignore[arg-type]
continue
all_results_nested.append(res)
# provider 内部 early_stop_at 已触发时不会再返回新 skill,提前 break 避免空轮询
if not res:
break
else:
completed = 0
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
async def _search_one(kw: str) -> list[SearchResult]:
nonlocal completed
try:
return await provider.search(kw, max_results=per_keyword)
finally:
completed += 1
if completed < total:
_emit(f"已完成 {completed}/{total} 次检索,正在继续检索相关内容…")
else:
_emit(f"已完成全部 {total} 次检索")
search_tasks = [_search_one(kw) for kw in search_keywords]
all_results_nested: list[list[SearchResult]] = await asyncio.gather(*search_tasks, return_exceptions=True)
search_tasks = [_search_one(kw) for kw in search_keywords]
all_results_nested = await asyncio.gather(*search_tasks, return_exceptions=True)
# 合并去重
# - 首次检索 / 普通 re_search(不带 extra_keywords,用户只想 LLM 重出):旧素材丢弃
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
# - PAUSE-1 追加检索 is_append_only:保留旧素材 + 仅检索新关键词
# - 技能检索 is_skill_search:覆盖式替换,旧素材完全丢弃(用户期望对比 web vs skill 结果)
seen_urls: set[str] = set()
seen_skill_ids: set[str] = set() # skill 素材无 URL,按 skill_name 去重
raw_materials: list[Material] = []
existing_pkg = state.get("material_package")
preserve_existing = existing_pkg and (is_supplement or is_append_only)
preserve_existing = existing_pkg and (is_supplement or is_append_only) and not is_skill_search
if preserve_existing:
for m in existing_pkg.get("materials", []):
seen_urls.add(m.get("url", ""))
@@ -205,10 +253,19 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
search_errors.append(f"{type(results).__name__}: {results}")
continue
for r in results:
if r.url and r.url in seen_urls:
# skill 素材按 skill_name 去重(同一个 skill 被多个关键词命中只保留一次)
if r.source_type == "skill":
meta = getattr(r, "extra_metadata", {}) or {}
sk_name = meta.get("skill_name", "")
if sk_name and sk_name in seen_skill_ids:
continue
if sk_name:
seen_skill_ids.add(sk_name)
elif r.url and r.url in seen_urls:
continue
seen_urls.add(r.url)
raw_materials.append(Material(
else:
seen_urls.add(r.url)
mat: Material = Material(
id=f"m{len(raw_materials)+1:03d}",
title=r.title,
content=r.content[:500],
@@ -218,7 +275,11 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
source_type=r.source_type,
url=r.url,
recUuid=r.recUuid,
))
)
extra = getattr(r, "extra_metadata", None)
if extra:
mat["extra_metadata"] = extra
raw_materials.append(mat)
# provider 内部吞掉的检索异常(如内网 ES 连接失败),一并取出用于提示用户
search_errors.extend(getattr(provider, "errors", []))
@@ -264,6 +325,14 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
old_kws = list(existing_pkg.get("keywords") or [])
pkg_keywords = list(dict.fromkeys(old_kws + keywords))
pkg = MaterialPackage(keywords=pkg_keywords, materials=materials, summary=summary)
# 技能检索时把过程步骤一并放进 pkg,前端在素材确认卡里能展示"使用了哪些技能"
if is_skill_search:
pkg["skill_search_used"] = True
pkg["skill_search_steps"] = skill_steps
elif existing_pkg and existing_pkg.get("skill_search_used") and (is_supplement or is_append_only):
# 保留之前一次技能检索的痕迹(不污染纯 web 检索)
pkg["skill_search_used"] = existing_pkg.get("skill_search_used", False)
pkg["skill_search_steps"] = existing_pkg.get("skill_search_steps", [])
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = []
@@ -281,12 +350,15 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
raw="\n\n----------\n\n".join(raw_dumps[:3]),
))
# 进度事件文案:追加模式下让用户看到只搜了哪几个新词
step_start_msg = (
f"追加检索关键词:{', '.join(keywords[:3])}"
if is_append_only
else f"搜索关键词:{', '.join(keywords[:3])}"
)
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
if is_skill_search:
step_start_msg = f"启用技能检索({len(skill_steps)} 步)"
elif is_user_query_search:
step_start_msg = f"按你的输入检索:{user_query[:40]}"
elif is_append_only:
step_start_msg = f"追加检索关键词:{', '.join(keywords[:3])}"
else:
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
return {
"material_package": pkg,
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
@@ -3,6 +3,7 @@ from .internet_search import InternetSearchProvider
from .intranet_stub import IntranetSearchProvider
from .demo_search import DemoSearchProvider
from .duckduckgo_search import DuckDuckGoSearchProvider
from .skill_search import SkillSearchProvider
def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider:
@@ -60,5 +61,6 @@ __all__ = [
"BaseSearchProvider", "SearchResult",
"InternetSearchProvider", "IntranetSearchProvider",
"DemoSearchProvider", "DuckDuckGoSearchProvider",
"SkillSearchProvider",
"get_search_provider",
]
@@ -0,0 +1,248 @@
"""技能检索 Provider:把已启用的 Skill 当作可检索的素材源。
流程(保持简单,对应素材确认卡上的"技能检索"按钮):
1) list_skills 事件 —— 列出当前启用的全部 skill
2) select_skills 事件 —— LLM 根据用户输入 + 写作意图按相关度选出若干个
3) invoke_skill 事件 —— 按相关度从高到低顺序产出 SearchResult
输出格式严格对齐 BaseSearchProvider 的 SearchResult,让 researcher 节点
不需要分支处理,下游 Material 字段同构。
"""
from __future__ import annotations
import logging
import re
from typing import Callable, List, Optional
from .base import BaseSearchProvider, SearchResult
logger = logging.getLogger(__name__)
_SELECT_SKILLS_SYSTEM = """你是一名情报研究员,需要从可用「技能」中挑出对写作任务有帮助的若干个,并按相关度从高到低排序。
技能是一段封装好的指令 / 知识 / 工作流,每个技能有 name 和 description。
【严禁思考】
- 这是一个简单的匹配任务,禁止任何思考、分析、推理过程,禁止输出 <think></think> 标签。
- 收到输入后立即直接输出最终 JSON,不得有任何前置文字。
【输出要求】
- 只输出一个 JSON 对象,不要 markdown 代码块。
- 必须使用英文双引号。
- selected_skills 中的 name 必须严格来自下方"可用技能"列表,不要编造,按相关度从高到低排序。
- 如果用户输入里明确点名了某个技能(按 name 完全匹配),把它排在第一位。
【完整示例】
{"selected_skills": ["web-search", "knowledge-lookup"], "reason": "用户想检索互联网信息,web-search 最相关,知识库查询作为补充"}"""
_SELECT_SKILLS_USER = """写作意图:{user_intent}
用户补充输入:{user_query}
检索关键词:{keywords}
可用技能(共 {skill_count} 个):
{skill_list}
请从中按相关度从高到低选出最多 {max_select} 个技能。如果没有合适的技能,selected_skills 返回空数组。
只输出 JSON:
{{"selected_skills": ["name1", "name2"], "reason": "为什么选这些 + 排序依据"}}
/no_think"""
EmitStepFn = Callable[[dict], None]
def _summarize_skill_content(content: str, limit: int = 500) -> str:
"""从 SKILL.md 提取摘要:跳过 frontmatter,取正文开头 limit 字。"""
if not content:
return ""
body = content
m = re.match(r"^---\s*\n(.*?\n)---\s*\n(.*)$", content, flags=re.DOTALL)
if m:
body = m.group(2)
body = re.sub(r"^#\s+.*?\n", "", body.lstrip(), count=1)
body = body.strip()
if len(body) > limit:
return body[:limit].rstrip() + "…"
return body
class SkillSearchProvider(BaseSearchProvider):
"""从启用的 Skills 中筛选并返回作为素材。
构造参数:
llm_select: 异步 LLM 调用 (system, user, fallback) -> dict。
emit_step: 可选;推送时间线步骤事件(list_skills / select_skills / invoke_skill)。
max_select: LLM 单次最多挑几个技能。
user_query: 用户在素材确认卡输入框写的自然语言(可能包含技能名 / 来源 / 补充意图)。
"""
def __init__(
self,
llm_select: Callable,
*,
emit_step: Optional[EmitStepFn] = None,
max_select: int = 5,
user_query: str = "",
) -> None:
self._llm_select = llm_select
self._emit_step = emit_step or (lambda _step: None)
self._max_select = max_select
self._user_query = (user_query or "").strip()
self._errors: list[str] = []
# 单次 researcher 调用内复用结果,避免每个 keyword 都问一次 LLM
self._selected_cache: list[dict] | None = None
self._enabled_skills: list | None = None
# 跨 keyword 调用时同一个 skill 只暴露一次
self._yielded_skills: set[str] = set()
@property
def errors(self) -> list[str]:
return list(self._errors)
def _load_enabled_skills(self) -> list:
if self._enabled_skills is not None:
return self._enabled_skills
try:
from deerflow.skills import get_or_new_skill_storage
storage = get_or_new_skill_storage()
skills = storage.load_skills(enabled_only=True)
except Exception as e:
logger.exception("ai_writing.skill_search: 加载启用技能失败")
self._errors.append(f"skill list error: {e}")
skills = []
self._enabled_skills = skills
self._emit_step({
"type": "list_skills",
"message": f"发现 {len(skills)} 个可用技能",
"skill_names": [s.name for s in skills],
})
return skills
async def _select_skills(self, query: str, user_intent: str) -> list[dict]:
"""让 LLM 从启用的技能中按相关度排序选出若干个。同一次检索复用结果。"""
if self._selected_cache is not None:
return self._selected_cache
skills = self._load_enabled_skills()
if not skills:
self._selected_cache = []
return []
skill_list_text = "\n".join(
f"- {s.name}: {s.description or '(无描述)'}" for s in skills
)
try:
result = await self._llm_select(
_SELECT_SKILLS_SYSTEM,
_SELECT_SKILLS_USER.format(
user_intent=user_intent or query,
user_query=self._user_query or "(无)",
keywords=query,
skill_count=len(skills),
skill_list=skill_list_text,
max_select=self._max_select,
),
fallback={"selected_skills": [s.name for s in skills[: self._max_select]]},
)
except Exception as e:
logger.exception("ai_writing.skill_search: LLM 选择技能失败")
self._errors.append(f"skill select error: {e}")
result = {"selected_skills": [s.name for s in skills[: self._max_select]]}
wanted_names = result.get("selected_skills") or []
if not isinstance(wanted_names, list):
wanted_names = []
by_name = {s.name: s for s in skills}
chosen: list[dict] = []
for name in wanted_names:
if not isinstance(name, str):
continue
sk = by_name.get(name.strip())
if sk:
chosen.append({"skill": sk, "reason": result.get("reason", "")})
if len(chosen) >= self._max_select:
break
self._emit_step({
"type": "select_skills",
"message": f"按相关度排序,候选 {len(chosen)} 个技能(将按顺序调用)",
"skill_names": [c["skill"].name for c in chosen],
"reason": result.get("reason", ""),
})
self._selected_cache = chosen
return chosen
@staticmethod
def _read_skill_content(skill) -> str:
try:
return skill.skill_file.read_text(encoding="utf-8")
except Exception as e:
logger.warning("ai_writing.skill_search: 读 SKILL.md 失败 %s: %s", skill.name, e)
return skill.description or ""
def _to_search_result(self, skill, reason: str) -> SearchResult:
raw = self._read_skill_content(skill)
summary = _summarize_skill_content(raw, limit=500)
category_value = skill.category.value if hasattr(skill.category, "value") else str(skill.category)
return SearchResult(
title=skill.name,
content=summary or (skill.description or skill.name),
source=f"技能库 / {category_value}",
published_at="",
url="",
relevance_score=1.0,
source_type="skill",
recUuid="",
extra_metadata={
"skill_name": skill.name,
"skill_description": skill.description or "",
"skill_path": skill.skill_path,
"container_path": skill.get_container_file_path(),
"category": category_value,
"select_reason": reason,
# 完整 SKILL.md 内容,供 writer_draft 节点按需注入到 prompt
"skill_full_content": raw,
},
)
async def search(
self,
query: str,
max_results: int = 5,
date_from: str = "",
date_to: str = "",
user_intent: str = "",
) -> List[SearchResult]:
"""按相关度顺序产出候选 skills 对应的 SearchResult。
说明:每个 skill 直接产出一条 SearchResult。researcher 节点上游会按
skill_name 去重,所以同一个 SkillSearchProvider 多次调用本方法时不会
产出重复 skill 素材(_yielded_skills 兜底)。
"""
chosen = await self._select_skills(query, user_intent=user_intent)
if not chosen:
return []
results: List[SearchResult] = []
for entry in chosen:
sk = entry["skill"]
if sk.name in self._yielded_skills:
continue
self._emit_step({
"type": "invoke_skill",
"message": f"调用技能:{sk.name}",
"skill_name": sk.name,
"matched_count": len(self._yielded_skills) + 1,
})
results.append(self._to_search_result(sk, entry.get("reason", "")))
self._yielded_skills.add(sk.name)
if len(results) >= max_results:
break
return results
async def health_check(self) -> bool:
try:
return bool(self._load_enabled_skills())
except Exception:
return False
@@ -3,21 +3,43 @@ import operator
# ── 素材 ──────────────────────────────────────────────────────────────────────
class Material(TypedDict):
class Material(TypedDict, total=False):
id: str
title: str
content: str # 截断到 500 字
source: str
published_at: str
relevance_score: float
source_type: str # "internet" | "intranet"
source_type: str # "internet" | "intranet" | "skill"
url: str
recUuid: str
# skill 类素材附带的元信息(skill_name / container_path / category),其他类型为空
extra_metadata: dict
class MaterialPackage(TypedDict):
class SkillStep(TypedDict, total=False):
"""技能检索过程的单步记录,用于前端时间线展示。
type=match_skills:根据用户输入流式分析有没有匹配技能(无 skill_names)
type=list_skills:列出可用技能
type=select_skills:LLM 从中选出的相关技能
type=invoke_skill:实际调用某一个技能检索
type=stop_early:达到素材阈值,提前停止后续技能调用
"""
type: str
message: str
skill_names: List[str]
skill_name: str
matched_count: int
reason: str
class MaterialPackage(TypedDict, total=False):
keywords: List[str]
materials: List[Material]
summary: str
# 是否本次素材来自技能检索(覆盖式替换 web 检索)
skill_search_used: bool
# 技能检索的步骤记录(前端展示"使用了 X 技能"卡片)
skill_search_steps: List[SkillStep]
# ── 大纲 ──────────────────────────────────────────────────────────────────────
# 三级结构:章节(##) → 小节(###) → 子要点(bullet -)。
@@ -72,10 +94,13 @@ class ReviewResult(TypedDict):
# ── 用户干预 ──────────────────────────────────────────────────────────────────
class UserInterventionPayload(TypedDict):
class UserInterventionPayload(TypedDict, total=False):
action: str
approved_material_ids: List[str]
extra_keywords: List[str]
# PAUSE-1 素材确认 / enable_skill_search 共用:用户在单输入框里填的自然语言
# (可能是要使用的技能名、想检索的来源、或者一段补充检索意图)。
user_query: str
edited_outline: Optional[Outline]
outline_feedback: str
user_revision_notes: str