From 1a122eb7fd8c6d87f005a441761a8dae862e33c0 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 29 May 2026 11:19:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/assets/icon/down-logo.svg | 1 + frontend-web/src/assets/icon/nav-active.svg | 1 + .../src/assets/icon/nav-line-logo.svg | 1 + frontend-web/src/components/page-sdebar.css | 44 +++- .../src/components/page-sidebar-dark.tsx | 3 +- .../components/ai-writing/AIWritingPanel.tsx | 12 + .../ai-writing/InterventionCard.tsx | 98 +++++-- .../components/ai-writing/MaterialItem.tsx | 10 +- .../ai-writing/MaterialsToolbar.tsx | 98 +++---- .../components/ai-writing/SkillStepsCard.tsx | 105 ++++++++ .../open-canvas/hooks/useAIWritingStream.ts | 42 ++- .../src/open-canvas/types/ai-writing.ts | 25 +- .../components/RecommendAgentsDialog.tsx | 33 ++- offline-backend-20260512/backend/config.yaml | 20 +- .../backend/extensions_config.json | 11 + .../deerflow/agents/ai_writing/graph.py | 19 +- .../agents/ai_writing/nodes/researcher.py | 146 ++++++++--- .../agents/ai_writing/search/__init__.py | 2 + .../agents/ai_writing/search/skill_search.py | 248 ++++++++++++++++++ .../deerflow/agents/ai_writing/state.py | 33 ++- 20 files changed, 812 insertions(+), 140 deletions(-) create mode 100644 frontend-web/src/assets/icon/down-logo.svg create mode 100644 frontend-web/src/assets/icon/nav-active.svg create mode 100644 frontend-web/src/assets/icon/nav-line-logo.svg create mode 100644 frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx create mode 100644 offline-backend-20260512/backend/extensions_config.json create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py diff --git a/frontend-web/src/assets/icon/down-logo.svg b/frontend-web/src/assets/icon/down-logo.svg new file mode 100644 index 00000000..19343061 --- /dev/null +++ b/frontend-web/src/assets/icon/down-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/icon/nav-active.svg b/frontend-web/src/assets/icon/nav-active.svg new file mode 100644 index 00000000..776703e6 --- /dev/null +++ b/frontend-web/src/assets/icon/nav-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/icon/nav-line-logo.svg b/frontend-web/src/assets/icon/nav-line-logo.svg new file mode 100644 index 00000000..0f31a8e2 --- /dev/null +++ b/frontend-web/src/assets/icon/nav-line-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 06ad158e..263fc14d 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -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; } \ No newline at end of file diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index cf24537d..dd841c64 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -332,8 +332,9 @@ export function PageSidebarDark({ {/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
+
{visibleMenuItems.map((item) => ( -
+
{item.label}
))} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index c9b5ba9f..e96ad767 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -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 && ( +
+ +
+ )} + {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 && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx index 6f652103..0230c061 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx @@ -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 ; + return ( + + ); } if (pausePoint === 'outline_confirm') { @@ -106,30 +119,40 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set void; disabled?: boolean; } -function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) { +function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) { const [selectedIds, setSelectedIds] = useState>( () => new Set(materials.map((m) => m.id)), ); - const [extraKeywords, setExtraKeywords] = useState([]); + 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

未检索到任何素材

- 素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。 + 素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。

+ {(skillSearchSteps.length > 0 || skillSearchInProgress) && ( + + )} + 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} /> -
+
+ @@ -182,11 +217,14 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC

需要您确认素材,才能继续

- 已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。 + 已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。 + 您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。

+ {skillSearchSteps.length > 0 && } + { 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} /> -
+
+ {!isSkillResult && ( + + )}
diff --git a/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx b/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx index 539d90ff..b8a6cace 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/MaterialItem.tsx @@ -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 (
{/* 头部行 */} @@ -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 && ( + + + 技能 + + )} {material.title} diff --git a/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx index aa38973e..1b149ed4 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx @@ -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; 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(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) { + 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) { + if (e.key === 'Enter' && !e.shiftKey && onSubmitQuery) { + e.preventDefault(); + onSubmitQuery(); + } } return ( @@ -51,50 +62,21 @@ export function MaterialsToolbar({
)} - {/* 补充关键词 */} + {/* 单输入框 */}
- -
- 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" - /> - -
- - {/* 已添加关键词 Tag 列表 */} - {extraKeywords.length > 0 && ( -
- {extraKeywords.map((kw) => ( - - {kw} - - - ))} -
- )} + +