feat(ai写作): 自带大纲/会商及研究模板等前后端改动
提交工作区中 AI 写作(表单/时间线/历史/上下文/流式 hook/类型)、 研究模板配置页、lead_agent、researcher/graph/state 及 gateway/config 的进行中改动。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d2c76100f2
commit
e9c8d1cea2
18 files changed
+287
-50
No files matched your search
@@ -3,6 +3,7 @@ import { PenLine, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -67,6 +68,7 @@ export function AIWritingForm({
|
||||
strictMode, setStrictMode,
|
||||
addReferences, setAddReferences,
|
||||
materialSource, setMaterialSource,
|
||||
skipResearch, setSkipResearch,
|
||||
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
|
||||
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
|
||||
selectedNotebookId, setSelectedNotebookId,
|
||||
@@ -164,13 +166,16 @@ export function AIWritingForm({
|
||||
: audience.trim() || DEFAULT_AUDIENCE,
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
// 跳过素材收集时强制 loose(无素材可严格依据,后端也会强制)
|
||||
writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
addReferences,
|
||||
materialSource: materialSource,
|
||||
skipResearch,
|
||||
// 跳过素材收集时检索类型无意义,归一为 general
|
||||
materialSource: skipResearch ? 'general' : materialSource,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
// 只有选了"我的空间"才传笔记参数
|
||||
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
|
||||
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
|
||||
// 只有选了"我的空间"且未跳过素材收集才传笔记参数
|
||||
noteSearchSpaceId: !skipResearch && materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
|
||||
noteSourceIds: !skipResearch && materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,6 +283,25 @@ export function AIWritingForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 不收集素材,直接用通用知识写作 */}
|
||||
<div className="mt-4 flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="skip-research"
|
||||
checked={skipResearch}
|
||||
onChange={(e) => setSkipResearch(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
|
||||
/>
|
||||
<label htmlFor="skip-research" className="cursor-pointer select-none">
|
||||
<span className="text-sm font-medium text-foreground/90">不收集素材,直接用通用知识写作</span>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
|
||||
勾选后跳过素材检索阶段(不弹素材确认),AI 直接用通用知识规划大纲并写作;检索类型与「严格依据素材」将被忽略。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!skipResearch && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>检索类型</label>
|
||||
<Select
|
||||
@@ -354,6 +378,7 @@ export function AIWritingForm({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>写作模型</label>
|
||||
@@ -416,15 +441,17 @@ export function AIWritingForm({
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
checked={strictMode && !skipResearch}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
|
||||
disabled={disabled || skipResearch}
|
||||
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<label htmlFor="strict-mode" className={cn('select-none', skipResearch ? 'cursor-not-allowed opacity-50' : 'cursor-pointer')}>
|
||||
<span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
{skipResearch
|
||||
? '已选「不收集素材」,本项不适用:将直接用通用知识写作。'
|
||||
: '勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。'}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ function normalizeCompletedIntervention(rec: Record<string, unknown>): Completed
|
||||
if (rr) out.reviewResult = normalizeReviewResult(rr);
|
||||
if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title;
|
||||
if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count;
|
||||
if (out.userInput == null && rec.user_input != null) out.userInput = rec.user_input;
|
||||
return out as unknown as CompletedIntervention;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
|
||||
import { TimelineStep } from './TimelineStep';
|
||||
import { CompletedInterventionCard } from './CompletedInterventionCard';
|
||||
import { UserRequirementCard } from './UserRequirementCard';
|
||||
|
||||
interface AIWritingTimelineProps {
|
||||
events: ProgressEvent[];
|
||||
@@ -48,8 +49,13 @@ export function AIWritingTimeline({
|
||||
awaitUserIdx += 1;
|
||||
|
||||
if (completedRecord) {
|
||||
// 已完成的干预点 → 渲染折叠历史卡
|
||||
slot = <CompletedInterventionCard record={completedRecord} />;
|
||||
// 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡
|
||||
slot = (
|
||||
<div className="space-y-2">
|
||||
<UserRequirementCard record={completedRecord} />
|
||||
<CompletedInterventionCard record={completedRecord} />
|
||||
</div>
|
||||
);
|
||||
} else if (currentPause && isLast) {
|
||||
// 当前活跃干预点 → 渲染操作卡
|
||||
slot = currentInterventionSlot;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { UserRound } from 'lucide-react';
|
||||
import type { CompletedIntervention } from '../../types/ai-writing';
|
||||
|
||||
/**
|
||||
* 时间线里「用户输入交互的要求」独立样式卡 —— 与 AI 步骤(灰)、完成卡(绿)、
|
||||
* 等待卡(黄)在视觉上明确区分:靛蓝主色 + 用户图标 + 顶部要求类型标签。
|
||||
*
|
||||
* 触发场景(record.userInput 非空时):
|
||||
* 素材确认 → 按用户要求补充检索内容
|
||||
* 大纲确认 → 用户提出大纲修改意见
|
||||
* 草稿确认 → 用户提出写作修改意见
|
||||
* 编辑意见处理 → 用户在编辑审核时提出处理意见
|
||||
*/
|
||||
|
||||
function requirementLabel(record: CompletedIntervention): string {
|
||||
switch (record.pausePoint) {
|
||||
case 'material_confirm':
|
||||
return '补充检索内容';
|
||||
case 'outline_confirm':
|
||||
return '修改大纲意见';
|
||||
case 'draft_confirm':
|
||||
return '修改写作意见';
|
||||
case 'review_confirm':
|
||||
return '编辑审核意见';
|
||||
default:
|
||||
return '我的要求';
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
record: CompletedIntervention;
|
||||
}
|
||||
|
||||
export function UserRequirementCard({ record }: Props) {
|
||||
if (!record.userInput) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-md border border-indigo-200 bg-indigo-50/60 dark:border-indigo-900/40 dark:bg-indigo-950/20 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<UserRound className="size-3.5 text-indigo-500 shrink-0" />
|
||||
<span className="text-[11px] font-semibold text-indigo-600 dark:text-indigo-400">
|
||||
我的要求 · {requirementLabel(record)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 pl-5 text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap break-words">
|
||||
{record.userInput}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { ArtifactsProvider } from '@/components/workspace/artifacts';
|
||||
import {
|
||||
MessageList,
|
||||
@@ -59,6 +61,8 @@ export function WritingSetupChat({
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||
const [chatModel, setChatModel] = useState('');
|
||||
// 快捷模式:开启后跳过逐项选项澄清(文章类型/字数/读者用默认),给标题直接生成大纲。
|
||||
const [quickMode, setQuickMode] = useState(false);
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { articleTypes } = useArticleTypes();
|
||||
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
|
||||
@@ -85,6 +89,7 @@ export function WritingSetupChat({
|
||||
context: {
|
||||
mode: 'flash',
|
||||
is_writing_setup: true,
|
||||
writing_setup_quick: quickMode,
|
||||
valid_article_types: validArticleTypes,
|
||||
model_name: chatModel || undefined,
|
||||
},
|
||||
@@ -133,7 +138,8 @@ export function WritingSetupChat({
|
||||
typeof payload.word_count_target === 'number' && payload.word_count_target > 0
|
||||
? payload.word_count_target
|
||||
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
|
||||
const materialSource = formCtx?.materialSource ?? 'general';
|
||||
const skipResearch = formCtx?.skipResearch ?? false;
|
||||
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
|
||||
|
||||
const request: AIWritingRequest = {
|
||||
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
|
||||
@@ -145,9 +151,11 @@ export function WritingSetupChat({
|
||||
targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者',
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: 1,
|
||||
writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose',
|
||||
// 跳过素材收集时强制 loose
|
||||
writingMode: skipResearch ? 'loose' : payload.writing_mode === 'strict' ? 'strict' : 'loose',
|
||||
// 跟随表单的「添加参考文献」开关(默认开)。
|
||||
addReferences: formCtx?.addReferences ?? true,
|
||||
skipResearch,
|
||||
materialSource,
|
||||
modelName: chatModel || formCtx?.modelName || undefined,
|
||||
noteSearchSpaceId:
|
||||
@@ -258,9 +266,25 @@ export function WritingSetupChat({
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden shrink-0 text-[11px] text-muted-foreground/70 sm:block">
|
||||
输入想法,按 Enter 直接解析填充
|
||||
</span>
|
||||
{/* 快捷模式开关:放在模型选择右侧。开启后跳过逐项选项确认,给标题直接出大纲。 */}
|
||||
<label
|
||||
className={cn(
|
||||
'flex shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition',
|
||||
quickMode
|
||||
? 'border-primary/40 bg-primary/5 text-primary dark:border-[#1B477E] dark:bg-[#022248]'
|
||||
: 'border-input bg-background text-muted-foreground dark:border-[#1B477E] dark:bg-[#022248]',
|
||||
)}
|
||||
title="开启后跳过逐项选项确认(文章类型/字数/读者用默认),给出标题即直接生成大纲"
|
||||
>
|
||||
<Zap className={cn('size-3.5 shrink-0', quickMode && 'fill-current')} />
|
||||
<span className="shrink-0">快捷模式</span>
|
||||
<Switch
|
||||
checked={quickMode}
|
||||
onCheckedChange={setQuickMode}
|
||||
disabled={thread.isLoading}
|
||||
className="ml-0.5 scale-90"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
|
||||
<PromptInputTextarea
|
||||
|
||||
@@ -289,6 +289,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
// 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。
|
||||
add_references: request.addReferences ?? false,
|
||||
material_source: request.materialSource,
|
||||
// 不收集素材,直接用通用知识写作:researcher 节点据此短路跳过检索
|
||||
skip_research: request.skipResearch ?? false,
|
||||
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||
notebook_source_ids: request.noteSourceIds ?? null,
|
||||
revision_count: 0,
|
||||
@@ -328,6 +330,17 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
|
||||
action: payload.action,
|
||||
};
|
||||
// 捕获用户在该干预点输入的自然语言要求,供时间线以「我的要求」独立样式回显:
|
||||
// 素材确认 → 补充检索内容(userQuery) / 大纲确认 → 修改意见(outlineFeedback) /
|
||||
// 草稿确认 + 编辑意见处理 → 修改·审核意见(userRevisionNotes)
|
||||
const userInput = (
|
||||
payload.userQuery ??
|
||||
payload.outlineFeedback ??
|
||||
payload.userRevisionNotes ??
|
||||
''
|
||||
).trim();
|
||||
if (userInput) record.userInput = userInput;
|
||||
|
||||
if (pausePoint === 'material_confirm') record.materials = state.materials;
|
||||
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
|
||||
else if (pausePoint === 'draft_confirm') {
|
||||
|
||||
@@ -39,6 +39,9 @@ export interface WritingFormValue {
|
||||
setAddReferences: (v: boolean) => void;
|
||||
materialSource: 'general' | 'notebook';
|
||||
setMaterialSource: (v: 'general' | 'notebook') => void;
|
||||
/** 不收集素材,直接用通用知识写作:勾选后跳过素材检索阶段(含素材确认暂停点)。默认关。 */
|
||||
skipResearch: boolean;
|
||||
setSkipResearch: (v: boolean) => void;
|
||||
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
|
||||
selectedNotebookId: number | null;
|
||||
setSelectedNotebookId: (v: number | null) => void;
|
||||
@@ -86,6 +89,7 @@ export function WritingFormProvider({
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [addReferences, setAddReferences] = useState(true);
|
||||
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
|
||||
const [skipResearch, setSkipResearch] = useState(false);
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
|
||||
const [modelName, setModelName] = useState('');
|
||||
@@ -123,12 +127,13 @@ export function WritingFormProvider({
|
||||
strictMode, setStrictMode,
|
||||
addReferences, setAddReferences,
|
||||
materialSource, setMaterialSource,
|
||||
skipResearch, setSkipResearch,
|
||||
selectedNotebookId, setSelectedNotebookId,
|
||||
selectedDocumentIds, setSelectedDocumentIds,
|
||||
modelName, setModelName,
|
||||
applyProposal,
|
||||
}),
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
);
|
||||
|
||||
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
|
||||
@@ -705,6 +705,8 @@ export type AIWritingValues = {
|
||||
article_type_detail?: string;
|
||||
/** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */
|
||||
add_references?: boolean;
|
||||
/** 不收集素材,直接用通用知识写作(researcher 节点据此短路跳过检索)。 */
|
||||
skip_research?: boolean;
|
||||
status?: string;
|
||||
material_package?: unknown;
|
||||
current_outline?: unknown;
|
||||
|
||||
@@ -143,7 +143,7 @@ export default function ResearchTemplateEditorPage() {
|
||||
const payload = toPayload(form);
|
||||
if (editing && id) await update(id, payload);
|
||||
else await create(payload);
|
||||
navigate(LIST_PATH);
|
||||
navigate(LIST_PATH, { replace: true });
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : '保存失败');
|
||||
setSaving(false);
|
||||
@@ -154,7 +154,7 @@ export default function ResearchTemplateEditorPage() {
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => navigate(LIST_PATH)}>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
@@ -168,7 +168,7 @@ export default function ResearchTemplateEditorPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{formError && <span className="text-sm text-destructive">{formError}</span>}
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(LIST_PATH)} disabled={saving}>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(-1)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useResearchTemplates } from '../hooks/useResearchTemplates';
|
||||
import { useArticleTypes } from '../hooks/useArticleTypes';
|
||||
|
||||
const AI_WRITING_PATH = '/page/canvas/ai-writing';
|
||||
const NEW_PATH = '/page/canvas/research-templates/new';
|
||||
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
|
||||
|
||||
@@ -48,7 +49,11 @@ export default function ResearchTemplatesConfigPage() {
|
||||
<div className="mx-auto h-full w-full max-w-4xl overflow-y-auto p-6">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => navigate(AI_WRITING_PATH, { replace: true })}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
|
||||
@@ -267,6 +267,9 @@ export interface AIWritingRequest {
|
||||
addReferences?: boolean;
|
||||
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
|
||||
materialSource: "general" | "notebook";
|
||||
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
|
||||
* 直接进入大纲规划;后端强制 loose 写作模式。默认 false。 */
|
||||
skipResearch?: boolean;
|
||||
/** 笔记来源(可选):空间 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
|
||||
@@ -316,6 +319,10 @@ export interface CompletedIntervention {
|
||||
| "review_confirm"
|
||||
| "section_help";
|
||||
action: string;
|
||||
/** 用户在该干预点输入的自然语言要求(补充检索内容 / 大纲修改意见 / 写作修改意见 /
|
||||
* 编辑审核处理意见)。仅当用户实际输入了文字时存在,用于在时间线里以「我的要求」
|
||||
* 独立样式回显——区别于 AI 步骤与绿色完成卡。 */
|
||||
userInput?: string;
|
||||
// material_confirm
|
||||
materials?: AIMaterial[];
|
||||
// outline_confirm
|
||||
|
||||
Reference in new issue
Block a user