大纲规划
This commit is contained in:
1 parent
49e5aaea6b
commit
17b28f43f8
5 files changed
+117
-58
No files matched your search
@@ -4,6 +4,13 @@ import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-r
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
|
|
||||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||||
import { useAIWriting } from '../../hooks/useAIWriting';
|
import { useAIWriting } from '../../hooks/useAIWriting';
|
||||||
@@ -40,9 +47,13 @@ export function AIWritingPanel() {
|
|||||||
const { setArtifact } = useCanvasGraphContext();
|
const { setArtifact } = useCanvasGraphContext();
|
||||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||||
|
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
|
||||||
|
|
||||||
|
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
|
||||||
|
|
||||||
// History view state — null means normal writing mode
|
// History view state — null means normal writing mode
|
||||||
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
|
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
|
||||||
|
const [reqDialogOpen, setReqDialogOpen] = useState(false);
|
||||||
|
|
||||||
const isIdle = state.status === 'idle';
|
const isIdle = state.status === 'idle';
|
||||||
const isDone = state.status === 'done';
|
const isDone = state.status === 'done';
|
||||||
@@ -203,7 +214,15 @@ export function AIWritingPanel() {
|
|||||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||||
{/* 头部 */}
|
{/* 头部 */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||||
<span className="text-sm font-semibold shrink-0">AI 智能写作</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-semibold shrink-0',
|
||||||
|
!isIdle && 'cursor-pointer hover:text-primary transition-colors',
|
||||||
|
)}
|
||||||
|
onClick={() => { if (!isIdle && state.request) setReqDialogOpen(true); }}
|
||||||
|
>
|
||||||
|
AI 智能写作
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -383,6 +402,47 @@ export function AIWritingPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 写作参数查看弹窗 */}
|
||||||
|
{state.request && (
|
||||||
|
<Dialog open={reqDialogOpen} onOpenChange={setReqDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>本次写作参数</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-1 text-sm">
|
||||||
|
<ReqRow label="写作意图">
|
||||||
|
<p className="text-muted-foreground whitespace-pre-wrap leading-relaxed">{state.request.userIntent}</p>
|
||||||
|
</ReqRow>
|
||||||
|
<ReqRow label="文章类型">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
|
||||||
|
</span>
|
||||||
|
</ReqRow>
|
||||||
|
<ReqRow label="目标读者">
|
||||||
|
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||||
|
</ReqRow>
|
||||||
|
<ReqRow label="目标字数">
|
||||||
|
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
||||||
|
</ReqRow>
|
||||||
|
{state.request.modelName && (
|
||||||
|
<ReqRow label="写作模型">
|
||||||
|
<span className="text-muted-foreground">{state.request.modelName}</span>
|
||||||
|
</ReqRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReqRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-xs font-medium text-foreground/60">{label}</p>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
PauseCircle, CheckCircle2, RotateCcw, Search,
|
PauseCircle, CheckCircle2, RotateCcw, Search,
|
||||||
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit,
|
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type {
|
import type {
|
||||||
@@ -222,9 +222,9 @@ interface OutlineConfirmCardProps {
|
|||||||
|
|
||||||
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
|
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
|
||||||
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
|
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
|
||||||
const [showReplan, setShowReplan] = useState(false);
|
|
||||||
const [feedback, setFeedback] = useState('');
|
const [feedback, setFeedback] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
async function handleConfirm() {
|
async function handleConfirm() {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -236,11 +236,28 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleReplan() {
|
async function handleReplan() {
|
||||||
|
const text = feedback.trim();
|
||||||
|
if (!text) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await onSubmit({ action: 're_outline', outlineFeedback: feedback.trim() });
|
await onSubmit({ action: 're_outline', outlineFeedback: text });
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleReplan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动调整高度
|
||||||
|
function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||||
|
setFeedback(e.target.value);
|
||||||
|
const el = e.target;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -248,7 +265,7 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium">请确认大纲后继续写作</p>
|
<p className="text-xs font-medium">请确认大纲后继续写作</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
可直接点击章节标题或要点进行编辑,或要求重新规划大纲。
|
可直接点击章节标题或要点进行编辑,或在下方输入意见让 AI 重新规划。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,40 +274,37 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
|
|||||||
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
|
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
|
||||||
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
|
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
|
||||||
|
|
||||||
{showReplan && (
|
{/* 重新规划输入框 */}
|
||||||
<div className="space-y-1.5">
|
<div className="flex items-end gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 focus-within:ring-1 focus-within:ring-ring">
|
||||||
<label className="text-xs text-muted-foreground">重新规划意见(可选)</label>
|
<textarea
|
||||||
<textarea
|
ref={textareaRef}
|
||||||
rows={2} value={feedback} onChange={(e) => setFeedback(e.target.value)}
|
rows={1}
|
||||||
placeholder="描述大纲调整方向,例如:增加案例分析章节…"
|
value={feedback}
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
onChange={handleInput}
|
||||||
/>
|
onKeyDown={handleKeyDown}
|
||||||
</div>
|
placeholder="输入修改意见后按 Enter 重新规划大纲,Shift+Enter 换行…"
|
||||||
)}
|
disabled={disabled || submitting}
|
||||||
|
className="flex-1 resize-none overflow-hidden bg-transparent text-xs placeholder:text-muted-foreground focus:outline-none disabled:opacity-50 leading-5 py-0.5 min-h-[20px] max-h-32"
|
||||||
<div className="flex gap-2 pt-1">
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
type="button"
|
||||||
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
|
onClick={handleReplan}
|
||||||
|
disabled={disabled || submitting || !feedback.trim()}
|
||||||
|
className="shrink-0 mb-0.5 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/8 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
title="发送(Enter)"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
|
{submitting
|
||||||
</Button>
|
? <RefreshCw className="size-3.5 animate-spin" />
|
||||||
{showReplan ? (
|
: <SendHorizonal className="size-3.5" />}
|
||||||
<Button
|
</button>
|
||||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
|
||||||
onClick={handleReplan} disabled={disabled || submitting}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-3.5" />提交重规划
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
|
||||||
onClick={() => setShowReplan(true)} disabled={disabled || submitting}
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-3.5" />重新规划…
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||||||
|
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
const INITIAL_STATE: AIWritingState = {
|
const INITIAL_STATE: AIWritingState = {
|
||||||
sessionId: null,
|
sessionId: null,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
request: null,
|
||||||
isPaused: false,
|
isPaused: false,
|
||||||
progressEvents: [],
|
progressEvents: [],
|
||||||
materials: [],
|
materials: [],
|
||||||
@@ -269,7 +270,7 @@ export function useAIWriting() {
|
|||||||
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = new AbortController();
|
abortRef.current = new AbortController();
|
||||||
setState({ ...INITIAL_STATE, status: 'researching' });
|
setState({ ...INITIAL_STATE, status: 'researching', request });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionId = await startWritingSession(request);
|
const sessionId = await startWritingSession(request);
|
||||||
|
|||||||
@@ -95,11 +95,9 @@ export default function ArticleTypesConfigPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const trimmedKey = form.key.trim();
|
|
||||||
const trimmedLabel = form.label.trim();
|
const trimmedLabel = form.label.trim();
|
||||||
const wordCount = parseInt(form.default_word_count, 10);
|
const wordCount = parseInt(form.default_word_count, 10);
|
||||||
|
|
||||||
if (!trimmedKey) { setFormError('标识不能为空'); return; }
|
|
||||||
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
|
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
|
||||||
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
|
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
|
||||||
|
|
||||||
@@ -116,8 +114,9 @@ export default function ArticleTypesConfigPage() {
|
|||||||
};
|
};
|
||||||
await update(editingId, req);
|
await update(editingId, req);
|
||||||
} else {
|
} else {
|
||||||
|
const autoKey = 't_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||||
const req: CreateArticleTypeRequest = {
|
const req: CreateArticleTypeRequest = {
|
||||||
key: trimmedKey,
|
key: autoKey,
|
||||||
label: trimmedLabel,
|
label: trimmedLabel,
|
||||||
description: form.description.trim() || undefined,
|
description: form.description.trim() || undefined,
|
||||||
prompt_hint: form.prompt_hint.trim() || undefined,
|
prompt_hint: form.prompt_hint.trim() || undefined,
|
||||||
@@ -211,19 +210,6 @@ export default function ArticleTypesConfigPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 py-1">
|
<div className="space-y-3 py-1">
|
||||||
<FormField label="标识 (key)" required>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLASS}
|
|
||||||
placeholder="如 news、analysis(传给 AI 的唯一标识,仅限字母数字下划线)"
|
|
||||||
value={form.key}
|
|
||||||
onChange={(e) => setField('key', e.target.value)}
|
|
||||||
disabled={!!editingId}
|
|
||||||
/>
|
|
||||||
{editingId && (
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">标识创建后不可修改</p>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="名称" required>
|
<FormField label="名称" required>
|
||||||
<input
|
<input
|
||||||
className={INPUT_CLASS}
|
className={INPUT_CLASS}
|
||||||
@@ -355,9 +341,6 @@ function ArticleTypeCard({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-semibold">{type.label}</span>
|
<span className="text-sm font-semibold">{type.label}</span>
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-muted text-muted-foreground border">
|
|
||||||
{type.key}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{type.description && (
|
{type.description && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export interface CompletedIntervention {
|
|||||||
export interface AIWritingState {
|
export interface AIWritingState {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
status: WritingStatus;
|
status: WritingStatus;
|
||||||
|
request: AIWritingRequest | null;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
progressEvents: ProgressEvent[];
|
progressEvents: ProgressEvent[];
|
||||||
materials: AIMaterial[];
|
materials: AIMaterial[];
|
||||||
|
|||||||
Reference in new issue
Block a user