大纲规划
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 { 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[];
|
||||
|
||||
Reference in new issue
Block a user