代码修改
This commit is contained in:
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 |
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user