feat: AI写作集成笔记来源,支持多文档多选检索
- 前端 AIWritingForm 新增笔记来源选择器(笔记本单选 + 文档多选 checkbox) - AIWritingRequest 类型新增 noteSearchSpaceId / noteSourceIds 字段 - AIMaterial.sourceType 新增 notebook 类型 - useAIWritingStream startWriting 时自动获取 notebook_token 并携带笔记参数 - 后端 AIWritingState 新增 notebook_search_space_id / notebook_source_ids 字段 - researcher 节点新增 _search_notebook,解析 API 返回的 documents/chunks, 每个 chunk 作为独立素材(source_type=notebook)混入素材包 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
22b93e32eb
commit
b749c78efa
6 files changed
+224
-5
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PenLine } from 'lucide-react';
|
||||
import { BookOpen, PenLine, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useModels } from '@/core/models/hooks';
|
||||
import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
|
||||
|
||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
@@ -46,7 +47,13 @@ export function AIWritingForm({
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [modelName, setModelName] = useState('');
|
||||
// 笔记来源:选中的 search_space_id 和多个 document_id
|
||||
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(null);
|
||||
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>([]);
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
@@ -98,6 +105,22 @@ export function AIWritingForm({
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotebookChange(val: string) {
|
||||
setSelectedNotebookId(val ? Number(val) : null);
|
||||
setSelectedDocumentIds([]);
|
||||
}
|
||||
|
||||
function clearNoteSelection() {
|
||||
setSelectedNotebookId(null);
|
||||
setSelectedDocumentIds([]);
|
||||
}
|
||||
|
||||
function toggleDocument(docId: number) {
|
||||
setSelectedDocumentIds((prev) =>
|
||||
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId],
|
||||
);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
@@ -112,6 +135,9 @@ export function AIWritingForm({
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
noteSearchSpaceId: selectedNotebookId ?? undefined,
|
||||
// 有勾选文档则传 document_id 列表,否则传空数组(搜整个笔记本)
|
||||
noteSourceIds: selectedNotebookId != null ? selectedDocumentIds : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -206,6 +232,82 @@ export function AIWritingForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 笔记来源(可选) */}
|
||||
<div className="mt-4 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookOpen className="size-3.5 text-[#8b6f6f]" />
|
||||
<span className="text-sm font-semibold text-foreground">笔记来源(可选)</span>
|
||||
</div>
|
||||
{selectedNotebookId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearNoteSelection}
|
||||
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
|
||||
onValueChange={handleNotebookChange}
|
||||
disabled={disabled || notebooksLoading}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择笔记本(可不选)'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
|
||||
{nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedNotebookId !== null && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sourcesLoading
|
||||
? '文档加载中…'
|
||||
: sources.filter((s) => s.document?.id != null).length === 0
|
||||
? '该笔记本暂无文档'
|
||||
: `选择文档(可多选;不选则搜索整个笔记本${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
|
||||
</p>
|
||||
{!sourcesLoading && (
|
||||
<div className="max-h-40 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
|
||||
{sources
|
||||
.filter((s) => s.document?.id != null)
|
||||
.map((s) => {
|
||||
const docId = s.document!.id;
|
||||
const checked = selectedDocumentIds.includes(docId);
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleDocument(docId)}
|
||||
disabled={disabled}
|
||||
className="size-3.5 shrink-0 accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-foreground truncate">
|
||||
{s.document?.title ?? `文档 ${docId}`}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !intent.trim()}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useStream } from '@langchain/langgraph-sdk/react';
|
||||
|
||||
import { getAPIClient } from '@/core/api';
|
||||
import { getStoredAuth } from '@/core/auth';
|
||||
import { fetchStudioToken } from '@/core/studio/api';
|
||||
|
||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
||||
@@ -720,6 +722,19 @@ export function useAIWriting() {
|
||||
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
||||
dispatch({ type: 'start', request });
|
||||
try {
|
||||
// 如果选择了笔记来源,获取 notebook token 用于后端检索
|
||||
let notebookToken = '';
|
||||
if (request.noteSearchSpaceId) {
|
||||
const email = getStoredAuth()?.email ?? '';
|
||||
if (email) {
|
||||
try {
|
||||
notebookToken = await fetchStudioToken(email);
|
||||
} catch {
|
||||
// 忽略 token 获取失败,后端会优雅降级
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await stream.submit(
|
||||
{
|
||||
user_intent: request.userIntent,
|
||||
@@ -738,9 +753,16 @@ export function useAIWriting() {
|
||||
current_draft: null,
|
||||
review_result: null,
|
||||
last_user_intervention: null,
|
||||
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||
notebook_source_ids: request.noteSourceIds ?? [],
|
||||
} as Partial<AIWritingValues>,
|
||||
{
|
||||
config: { configurable: { model_name: request.modelName ?? '' } } as Record<string, unknown>,
|
||||
config: {
|
||||
configurable: {
|
||||
model_name: request.modelName ?? '',
|
||||
...(notebookToken ? { notebook_token: notebookToken } : {}),
|
||||
},
|
||||
} as Record<string, unknown>,
|
||||
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AIMaterial {
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
relevanceScore: number;
|
||||
sourceType: 'internet' | 'intranet' | 'skill';
|
||||
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
||||
@@ -227,6 +227,10 @@ export interface AIWritingRequest {
|
||||
keywordCount: number;
|
||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||
writingMode: 'strict' | 'loose';
|
||||
/** 笔记来源(可选):笔记本 search_space_id */
|
||||
noteSearchSpaceId?: number;
|
||||
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||
noteSourceIds?: number[];
|
||||
}
|
||||
|
||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user