大纲规划

This commit is contained in:
董宏凯 committed 2026-05-18 21:43:48 +08:00
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 { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../../hooks/useArticleTypes';
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
import { useAIWriting } from '../../hooks/useAIWriting';
@@ -40,9 +47,13 @@ export function AIWritingPanel() {
const { setArtifact } = useCanvasGraphContext();
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
// History view state — null means normal writing mode
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
const [reqDialogOpen, setReqDialogOpen] = useState(false);
const isIdle = state.status === 'idle';
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 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">
<Button
variant="ghost"
@@ -383,6 +402,47 @@ export function AIWritingPanel() {
)}
</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>
);
}
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
@@ -222,9 +222,9 @@ interface OutlineConfirmCardProps {
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
const [showReplan, setShowReplan] = useState(false);
const [feedback, setFeedback] = useState('');
const [submitting, setSubmitting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
async function handleConfirm() {
setSubmitting(true);
@@ -236,11 +236,28 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
}
async function handleReplan() {
const text = feedback.trim();
if (!text) return;
setSubmitting(true);
await onSubmit({ action: 're_outline', outlineFeedback: feedback.trim() });
await onSubmit({ action: 're_outline', outlineFeedback: text });
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 (
<div className="rounded-md border bg-background space-y-3 p-3">
<div className="flex items-start gap-2">
@@ -248,7 +265,7 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
<div>
<p className="text-xs font-medium">请确认大纲后继续写作</p>
<p className="text-xs text-muted-foreground mt-0.5">
可直接点击章节标题或要点进行编辑,或要求重新规划大纲。
可直接点击章节标题或要点进行编辑,或在下方输入意见让 AI 重新规划。
</p>
</div>
</div>
@@ -257,40 +274,37 @@ function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: Out
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
{showReplan && (
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">重新规划意见(可选)</label>
<textarea
rows={2} value={feedback} onChange={(e) => setFeedback(e.target.value)}
placeholder="描述大纲调整方向,例如:增加案例分析章节…"
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"
/>
</div>
)}
<div className="flex gap-2 pt-1">
<Button
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
{/* 重新规划输入框 */}
<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">
<textarea
ref={textareaRef}
rows={1}
value={feedback}
onChange={handleInput}
onKeyDown={handleKeyDown}
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"
/>
<button
type="button"
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" />确认大纲,开始写作
</Button>
{showReplan ? (
<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>
)}
{submitting
? <RefreshCw className="size-3.5 animate-spin" />
: <SendHorizonal className="size-3.5" />}
</button>
</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>
);
}
@@ -21,6 +21,7 @@ import type {
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
request: null,
isPaused: false,
progressEvents: [],
materials: [],
@@ -269,7 +270,7 @@ export function useAIWriting() {
abortRef.current?.abort();
abortRef.current = new AbortController();
setState({ ...INITIAL_STATE, status: 'researching' });
setState({ ...INITIAL_STATE, status: 'researching', request });
try {
const sessionId = await startWritingSession(request);
@@ -95,11 +95,9 @@ export default function ArticleTypesConfigPage() {
}
async function handleSave() {
const trimmedKey = form.key.trim();
const trimmedLabel = form.label.trim();
const wordCount = parseInt(form.default_word_count, 10);
if (!trimmedKey) { setFormError('标识不能为空'); return; }
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
@@ -116,8 +114,9 @@ export default function ArticleTypesConfigPage() {
};
await update(editingId, req);
} else {
const autoKey = 't_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const req: CreateArticleTypeRequest = {
key: trimmedKey,
key: autoKey,
label: trimmedLabel,
description: form.description.trim() || undefined,
prompt_hint: form.prompt_hint.trim() || undefined,
@@ -211,19 +210,6 @@ export default function ArticleTypesConfigPage() {
</DialogHeader>
<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>
<input
className={INPUT_CLASS}
@@ -355,9 +341,6 @@ function ArticleTypeCard({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<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>
{type.description && (
<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 {
sessionId: string | null;
status: WritingStatus;
request: AIWritingRequest | null;
isPaused: boolean;
progressEvents: ProgressEvent[];
materials: AIMaterial[];