568 lines
22 KiB
TypeScript
568 lines
22 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
|
||
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/useAIWritingStream';
|
||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||
import { adminCleanupAIWritingSessions } from '../../api/ai-writing-sessions';
|
||
|
||
/**
|
||
* 暂停 / 继续功能开关(默认 false = 关)。
|
||
*
|
||
* 历史背景:legacy 路径下,为了让用户点"停止"后还能"继续",引入了 per-worker
|
||
* 内存 dict(``_event_queues`` / ``_session_pending_pause``)。多 worker 部署下
|
||
* 这些 dict 互相看不见 → SSE 重连失败 / "会话丢失" → 触发了整个 ai-writing-优化.md
|
||
* 的迁移计划。
|
||
*
|
||
* 当前 LangGraph Server 路径下架构已无 per-worker 状态,但 ``stream.stop()`` 仅
|
||
* 取消客户端 SSE 订阅,**不会真正暂停后端 run**(LLM 继续烧 token);``resume``
|
||
* 也无法重新订阅同一个 run。这是个产品语义没想清楚的功能,先关掉。
|
||
*
|
||
* 后期想清楚怎么做"真·暂停"(``client.runs.cancel(run_id, action='interrupt')``
|
||
* 保留 checkpoint)+ "真·继续"(``client.runs.joinStream`` 续上原 run)时,
|
||
* 把这个 flag 翻成 true 即可,UI / hook 全套代码都已就位。
|
||
*/
|
||
const ENABLE_PAUSE_RESUME_UI = false;
|
||
import { AIWritingForm } from './AIWritingForm';
|
||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||
import { InterventionCard } from './InterventionCard';
|
||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||
import { DraftStreamingView } from './DraftStreamingView';
|
||
import { IntentResultCard } from './IntentResultCard';
|
||
import { IntentStreamingView } from './IntentStreamingView';
|
||
import { OutlineStreamingView } from './OutlineStreamingView';
|
||
import { ReviewStreamingView } from './ReviewStreamingView';
|
||
import { RevisionSummaryCard } from './RevisionSummaryCard';
|
||
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
|
||
import { AIWritingHistoryView } from './AIWritingHistoryView';
|
||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||
|
||
const STATUS_LABEL: Record<string, string> = {
|
||
idle: '就绪',
|
||
researching: '素材收集专家搜集素材中…',
|
||
awaiting_material_confirm: '等待确认素材',
|
||
writing_outline: '作家规划大纲中…',
|
||
awaiting_outline_confirm: '等待确认大纲',
|
||
writing_draft: '作家写作中…',
|
||
awaiting_section_help: '等待处理素材不足的章节',
|
||
awaiting_draft_confirm: '等待确认草稿',
|
||
reviewing: '编辑审核中…',
|
||
awaiting_review_confirm: '等待处理编辑意见',
|
||
revising: '按意见修改中…',
|
||
done: '写作完成 ✓',
|
||
error: '出错了',
|
||
};
|
||
|
||
export function AIWritingPanel({
|
||
hideAdvancedFields = false,
|
||
}: {
|
||
hideAdvancedFields?: boolean;
|
||
} = {}) {
|
||
const navigate = useNavigate();
|
||
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';
|
||
const isError = state.status === 'error';
|
||
const isRunning = !isIdle && !isDone && !isError;
|
||
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
|
||
|
||
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
|
||
const isAutoScrollingRef = useRef(false);
|
||
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
|
||
const autoScrollRef = useRef(true);
|
||
const [autoScroll, setAutoScroll] = useState(true);
|
||
|
||
const disableAutoScroll = useCallback(() => {
|
||
autoScrollRef.current = false;
|
||
setAutoScroll(false);
|
||
}, []);
|
||
|
||
// 新会话 / 重置后恢复自动滚底
|
||
useEffect(() => {
|
||
if (isIdle) {
|
||
autoScrollRef.current = true;
|
||
setAutoScroll(true);
|
||
}
|
||
}, [isIdle]);
|
||
|
||
// 开始写作 / 点击继续后恢复自动滚底
|
||
useEffect(() => {
|
||
if (isRunning && !state.isPaused) {
|
||
autoScrollRef.current = true;
|
||
setAutoScroll(true);
|
||
}
|
||
}, [isRunning, state.isPaused]);
|
||
|
||
// 监听用户手动滚动(挂载一次)
|
||
useEffect(() => {
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
|
||
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
|
||
const onWheel = (e: WheelEvent) => {
|
||
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
|
||
};
|
||
|
||
// scroll 事件:兼容拖动滚动条的场景
|
||
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
|
||
let lastTop = el.scrollTop;
|
||
const onScroll = () => {
|
||
const cur = el.scrollTop;
|
||
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
|
||
disableAutoScroll();
|
||
}
|
||
lastTop = cur;
|
||
};
|
||
|
||
el.addEventListener('wheel', onWheel, { passive: true });
|
||
el.addEventListener('scroll', onScroll, { passive: true });
|
||
return () => {
|
||
el.removeEventListener('wheel', onWheel);
|
||
el.removeEventListener('scroll', onScroll);
|
||
};
|
||
}, [disableAutoScroll]);
|
||
|
||
// 内容变化时若自动滚底开启则滚到底部
|
||
useEffect(() => {
|
||
// 检查同步 ref,避免 state 异步导致的时序竞争
|
||
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
|
||
const el = scrollRef.current;
|
||
isAutoScrollingRef.current = true;
|
||
el.scrollTop = el.scrollHeight;
|
||
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
|
||
}, [
|
||
autoScroll, // 点击按钮后重新触发一次
|
||
isRunning,
|
||
state.progressEvents.length,
|
||
state.streamingDraftSections.length,
|
||
state.streamingReviewItems.length,
|
||
state.outlineStreamingText,
|
||
state.currentPause,
|
||
]);
|
||
|
||
const scrollToBottom = useCallback(() => {
|
||
const el = scrollRef.current;
|
||
if (!el) return;
|
||
isAutoScrollingRef.current = true;
|
||
el.scrollTop = el.scrollHeight;
|
||
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
|
||
autoScrollRef.current = true;
|
||
setAutoScroll(true);
|
||
}, []);
|
||
|
||
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
|
||
setHistorySession(session);
|
||
// Push historical draft to the right panel
|
||
if (session.draft_markdown) {
|
||
setArtifact({
|
||
currentIndex: 0,
|
||
contents: [{
|
||
index: 0,
|
||
type: 'text',
|
||
title: session.draft_title ?? session.title,
|
||
fullMarkdown: session.draft_markdown,
|
||
}],
|
||
});
|
||
} else {
|
||
setArtifact(null);
|
||
}
|
||
}, [setArtifact]);
|
||
|
||
const handleNewSession = useCallback(() => {
|
||
setHistorySession(null);
|
||
reset();
|
||
}, [reset]);
|
||
|
||
// ── 侧边栏"最近写作"通信:两种路径 ────────────────────────────────────────
|
||
// 用 ref 包住 handler,保证事件监听器始终能拿到最新版本
|
||
const handleSelectHistoryRef = useRef(handleSelectHistory);
|
||
useEffect(() => {
|
||
handleSelectHistoryRef.current = handleSelectHistory;
|
||
}, [handleSelectHistory]);
|
||
|
||
// 路径1:导航进入本页面时,从 sessionStorage 恢复待打开的 session
|
||
useEffect(() => {
|
||
const raw = sessionStorage.getItem('ai-writing:pending-session');
|
||
if (!raw) return;
|
||
sessionStorage.removeItem('ai-writing:pending-session');
|
||
try {
|
||
const session = JSON.parse(raw) as AIWritingSessionSummary;
|
||
handleSelectHistoryRef.current(session);
|
||
} catch { /* ignore */ }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []); // 仅挂载时执行一次
|
||
|
||
// 路径2:已在本页面时,监听侧边栏触发的自定义事件
|
||
useEffect(() => {
|
||
function onOpenSession(e: Event) {
|
||
const session = (e as CustomEvent<AIWritingSessionSummary>).detail;
|
||
if (session) handleSelectHistoryRef.current(session);
|
||
}
|
||
window.addEventListener('ai-writing:open-session', onOpenSession);
|
||
return () => window.removeEventListener('ai-writing:open-session', onOpenSession);
|
||
}, []);
|
||
|
||
// ── ⚠️ 临时调试:admin 一键清理所有过期会话 ────────────────────────────────
|
||
// ai-writing-优化.md §阶段 7 删冗余代码时,本按钮 + 对应 api 一并删除。
|
||
// 仅 admin 能成功调用(后端 _require_admin),普通用户调会拿到 403。
|
||
const handleAdminCleanup = useCallback(async () => {
|
||
if (!window.confirm('确认清理:retention_days=0 会清掉所有 done/error 的历史会话,正在跑的会跳过。继续?')) {
|
||
return;
|
||
}
|
||
try {
|
||
const result = await adminCleanupAIWritingSessions({ retention_days: 0, only_finished: true });
|
||
toast.success(`已清理:DB ${result.deleted_db} 行,checkpoint ${result.deleted_checkpoints} 条,跳过活跃 ${result.skipped_active}`);
|
||
// 刷新历史列表
|
||
refresh();
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : String(e));
|
||
}
|
||
}, [refresh]);
|
||
|
||
const showNewButton = historySession !== null || state.status !== 'idle';
|
||
|
||
// History view mode
|
||
if (historySession) {
|
||
return (
|
||
<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 truncate flex-1 min-w-0 mr-2">
|
||
{historySession.draft_title ?? historySession.title}
|
||
</span>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={() => navigate('/page/canvas/article-types')}
|
||
title="文章类型配置"
|
||
>
|
||
<Settings2 className="size-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||
<Plus className="size-3" />
|
||
新建对话
|
||
</Button>
|
||
<AIWritingHistoryDropdown
|
||
sessions={sessions}
|
||
selectedId={historySession.id}
|
||
loading={sessionsLoading}
|
||
onSelect={handleSelectHistory}
|
||
onDelete={remove}
|
||
onRename={rename}
|
||
onRefresh={refresh}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<AIWritingHistoryView session={historySession} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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={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">
|
||
{/* ⚠️ 临时调试:admin 一键清理 —— 阶段 7 删冗余时移除 */}
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||
onClick={handleAdminCleanup}
|
||
title="[admin 调试] 清理所有过期会话"
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7"
|
||
onClick={() => navigate('/page/canvas/article-types')}
|
||
title="文章类型配置"
|
||
>
|
||
<Settings2 className="size-3.5" />
|
||
</Button>
|
||
{showNewButton && (
|
||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||
<Plus className="size-3" />
|
||
新建
|
||
</Button>
|
||
)}
|
||
{/* ⏸️/▶️ "停止 / 继续" 由 ENABLE_PAUSE_RESUME_UI 控制(默认 false 隐藏)。
|
||
后期把 flag 翻成 true 即可恢复,UI / hook 全套代码都已就位。 */}
|
||
{ENABLE_PAUSE_RESUME_UI && isRunning && !state.isPaused && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||
onClick={pauseWriting}
|
||
>
|
||
<Square className="size-3 fill-current" />
|
||
停止
|
||
</Button>
|
||
)}
|
||
{ENABLE_PAUSE_RESUME_UI && state.isPaused && (
|
||
<>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
|
||
onClick={resumeWriting}
|
||
>
|
||
<Play className="size-3 fill-current" />
|
||
继续
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||
<RotateCcw className="size-3" />
|
||
重置
|
||
</Button>
|
||
</>
|
||
)}
|
||
{(isDone || isError) && (
|
||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||
<RotateCcw className="size-3" />
|
||
重置
|
||
</Button>
|
||
)}
|
||
<AIWritingHistoryDropdown
|
||
sessions={sessions}
|
||
selectedId={null}
|
||
loading={sessionsLoading}
|
||
onSelect={handleSelectHistory}
|
||
onDelete={remove}
|
||
onRename={rename}
|
||
onRefresh={refresh}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
|
||
<div className="flex-1 min-h-0 relative">
|
||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||
{/* 未开始时显示表单 */}
|
||
{isIdle && (
|
||
<AIWritingForm
|
||
onSubmit={startWriting}
|
||
hideAdvancedFields={hideAdvancedFields}
|
||
/>
|
||
)}
|
||
|
||
{/* 意图解析:流式 → 结果卡片承接。
|
||
放在 timeline 之上 —— 解析后 timeline 会从 step_done 起一直长,
|
||
如果把意图块塞在 timeline 下面就会随 timeline 增长被推走;
|
||
放上面则恒定锚定在内容最顶部,刷到底也能滚回顶部看清楚意图理解结果。 */}
|
||
{(state.intentStreamingText || state.intentThinkingText) && (
|
||
<IntentStreamingView text={state.intentStreamingText} thinkingText={state.intentThinkingText} />
|
||
)}
|
||
{state.parsedIntent && !state.intentStreamingText && (
|
||
<IntentResultCard
|
||
intent={state.parsedIntent}
|
||
articleTypes={articleTypes}
|
||
hideTargetAudience={hideAdvancedFields}
|
||
/>
|
||
)}
|
||
|
||
{/* 运行中或完成后显示时间线 */}
|
||
{!isIdle && (
|
||
<AIWritingTimeline
|
||
events={state.progressEvents}
|
||
completedInterventions={state.completedInterventions}
|
||
currentPause={state.currentPause}
|
||
currentInterventionSlot={
|
||
state.currentPause ? (
|
||
<InterventionCard
|
||
pauseEvent={state.currentPause}
|
||
materials={state.materials}
|
||
outline={state.outline}
|
||
reviewResult={state.reviewResult}
|
||
draftTitle={state.currentDraftTitle}
|
||
revisionCount={state.revisionCount}
|
||
onSubmit={submitIntervention}
|
||
disabled={false}
|
||
/>
|
||
) : undefined
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{/* 大纲流式思考框 */}
|
||
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
|
||
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
|
||
)}
|
||
|
||
{/* 编辑审核流式结果 */}
|
||
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
|
||
<ReviewStreamingView items={state.streamingReviewItems} />
|
||
)}
|
||
|
||
{/* 修改完成摘要 */}
|
||
{state.revisionSummary && (
|
||
<div className="px-4 pb-2">
|
||
<RevisionSummaryCard summary={state.revisionSummary} />
|
||
</div>
|
||
)}
|
||
|
||
{/* 草稿流式打字机视图 */}
|
||
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
|
||
<DraftStreamingView
|
||
title={state.streamingDraftTitle}
|
||
sections={state.streamingDraftSections}
|
||
/>
|
||
)}
|
||
|
||
{showSkeleton &&
|
||
state.status !== 'writing_draft' &&
|
||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
|
||
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
|
||
!state.intentStreamingText && !state.intentThinkingText && (
|
||
<div className="px-4 pb-4">
|
||
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
|
||
</div>
|
||
)}
|
||
|
||
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
|
||
<div className="px-4 pb-4">
|
||
<WritingSkeletonCard status="writing_draft" />
|
||
</div>
|
||
)}
|
||
|
||
{isError && state.error && (
|
||
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||
{state.error}
|
||
</div>
|
||
)}
|
||
</div>{/* 滚动内容区结束 */}
|
||
|
||
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
|
||
{isRunning && !autoScroll && (
|
||
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
|
||
<button
|
||
onClick={scrollToBottom}
|
||
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
|
||
>
|
||
<ChevronsDown className="size-3.5" />
|
||
回到底部
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>{/* 相对定位容器结束 */}
|
||
|
||
{/* 状态栏 */}
|
||
{!isIdle && (
|
||
<div
|
||
className={cn(
|
||
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
|
||
isError ? 'text-destructive' : 'text-muted-foreground',
|
||
)}
|
||
>
|
||
{ENABLE_PAUSE_RESUME_UI && state.isPaused ? (
|
||
<>
|
||
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
|
||
已暂停
|
||
</>
|
||
) : (
|
||
<>
|
||
{isRunning && (
|
||
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||
)}
|
||
{state.status === 'researching' && state.researchProgress
|
||
? state.researchProgress
|
||
: STATUS_LABEL[state.status] ?? state.status}
|
||
</>
|
||
)}
|
||
</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>
|
||
{!hideAdvancedFields && (
|
||
<ReqRow label="目标读者">
|
||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||
</ReqRow>
|
||
)}
|
||
<ReqRow label="目标字数">
|
||
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
||
</ReqRow>
|
||
{!hideAdvancedFields && (
|
||
<>
|
||
<ReqRow label="检索关键词数量">
|
||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||
</ReqRow>
|
||
<ReqRow label="写作模式">
|
||
<span className="text-muted-foreground">
|
||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||
</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>
|
||
);
|
||
}
|