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
@@ -57,6 +57,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useModels } from '@/core/models/hooks';
|
import { useModels } from '@/core/models/hooks';
|
||||||
|
import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
|
||||||
|
|
||||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
@@ -46,7 +47,13 @@ export function AIWritingForm({
|
|||||||
const [wordCountInput, setWordCountInput] = useState('800');
|
const [wordCountInput, setWordCountInput] = useState('800');
|
||||||
const [strictMode, setStrictMode] = useState(false);
|
const [strictMode, setStrictMode] = useState(false);
|
||||||
const [modelName, setModelName] = useState('');
|
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 { models, isLoading: modelsLoading } = useModels();
|
||||||
|
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks();
|
||||||
|
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId);
|
||||||
|
|
||||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
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) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!intent.trim()) return;
|
if (!intent.trim()) return;
|
||||||
@@ -112,6 +135,9 @@ export function AIWritingForm({
|
|||||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||||
modelName: selectedModel?.name || modelName || undefined,
|
modelName: selectedModel?.name || modelName || undefined,
|
||||||
|
noteSearchSpaceId: selectedNotebookId ?? undefined,
|
||||||
|
// 有勾选文档则传 document_id 列表,否则传空数组(搜整个笔记本)
|
||||||
|
noteSourceIds: selectedNotebookId != null ? selectedDocumentIds : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +232,82 @@ export function AIWritingForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled || !intent.trim()}
|
disabled={disabled || !intent.trim()}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
|||||||
import { useStream } from '@langchain/langgraph-sdk/react';
|
import { useStream } from '@langchain/langgraph-sdk/react';
|
||||||
|
|
||||||
import { getAPIClient } from '@/core/api';
|
import { getAPIClient } from '@/core/api';
|
||||||
|
import { getStoredAuth } from '@/core/auth';
|
||||||
|
import { fetchStudioToken } from '@/core/studio/api';
|
||||||
|
|
||||||
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
|
||||||
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
|
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;
|
if (state.status !== 'idle' && state.status !== 'done' && state.status !== 'error') return;
|
||||||
dispatch({ type: 'start', request });
|
dispatch({ type: 'start', request });
|
||||||
try {
|
try {
|
||||||
|
// 如果选择了笔记来源,获取 notebook token 用于后端检索
|
||||||
|
let notebookToken = '';
|
||||||
|
if (request.noteSearchSpaceId) {
|
||||||
|
const email = getStoredAuth()?.email ?? '';
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
notebookToken = await fetchStudioToken(email);
|
||||||
|
} catch {
|
||||||
|
// 忽略 token 获取失败,后端会优雅降级
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await stream.submit(
|
await stream.submit(
|
||||||
{
|
{
|
||||||
user_intent: request.userIntent,
|
user_intent: request.userIntent,
|
||||||
@@ -738,9 +753,16 @@ export function useAIWriting() {
|
|||||||
current_draft: null,
|
current_draft: null,
|
||||||
review_result: null,
|
review_result: null,
|
||||||
last_user_intervention: null,
|
last_user_intervention: null,
|
||||||
|
notebook_search_space_id: request.noteSearchSpaceId ?? null,
|
||||||
|
notebook_source_ids: request.noteSourceIds ?? [],
|
||||||
} as Partial<AIWritingValues>,
|
} 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'],
|
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface AIMaterial {
|
|||||||
source: string;
|
source: string;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
relevanceScore: number;
|
relevanceScore: number;
|
||||||
sourceType: 'internet' | 'intranet' | 'skill';
|
sourceType: 'internet' | 'intranet' | 'skill' | 'notebook';
|
||||||
url?: string;
|
url?: string;
|
||||||
recUuid?: string;
|
recUuid?: string;
|
||||||
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
||||||
@@ -227,6 +227,10 @@ export interface AIWritingRequest {
|
|||||||
keywordCount: number;
|
keywordCount: number;
|
||||||
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
/** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */
|
||||||
writingMode: 'strict' | 'loose';
|
writingMode: 'strict' | 'loose';
|
||||||
|
/** 笔记来源(可选):笔记本 search_space_id */
|
||||||
|
noteSearchSpaceId?: number;
|
||||||
|
/** 笔记来源(可选):笔记本内指定文档的 document_id 列表,空数组表示检索整个笔记本 */
|
||||||
|
noteSourceIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+89
-2
@@ -1,8 +1,9 @@
|
|||||||
"""素材收集专家节点:关键词提取 → 并发搜索 → 相关度排序。"""
|
"""素材收集专家节点:关键词提取 → 并发搜索(含笔记) → 相关度排序。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -20,6 +21,10 @@ from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
|
||||||
|
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
|
||||||
|
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
|
||||||
|
|
||||||
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
|
# 素材总量上限边界:config.yaml 的 ai_writing.max_materials,非法值回退默认 50
|
||||||
_DEFAULT_MAX_MATERIALS = 50
|
_DEFAULT_MAX_MATERIALS = 50
|
||||||
_MIN_MAX_MATERIALS = 1
|
_MIN_MAX_MATERIALS = 1
|
||||||
@@ -89,6 +94,65 @@ def _notice(title: str, detail: str, raw: str = "") -> dict:
|
|||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_notebook(
|
||||||
|
keywords: list[str],
|
||||||
|
search_space_id: int,
|
||||||
|
source_ids: list[int],
|
||||||
|
token: str,
|
||||||
|
) -> list[Material]:
|
||||||
|
"""从笔记 API 按关键词检索内容,返回 source_type='notebook' 的 Material 列表。"""
|
||||||
|
import httpx # 懒导入:仅在笔记模式下才需要
|
||||||
|
|
||||||
|
base = (os.getenv("DEER_FLOW_NOTEBOOK_API_BASE") or "").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
logger.warning("DEER_FLOW_NOTEBOOK_API_BASE not set, skipping notebook search")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results: list[Material] = []
|
||||||
|
mat_idx = 0
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
for kw in keywords[:_NOTEBOOK_MAX_KEYWORDS]:
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{base}{_NOTEBOOK_SEARCH_PATH}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={
|
||||||
|
"query": kw,
|
||||||
|
"source_ids": source_ids,
|
||||||
|
"search_space_id": search_space_id,
|
||||||
|
"top_k": _NOTEBOOK_TOP_K,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
# 解析 documents 列表,每个 chunk 作为独立素材
|
||||||
|
documents: list[dict] = data.get("documents") if isinstance(data, dict) else []
|
||||||
|
for doc in documents or []:
|
||||||
|
doc_title: str = doc.get("title") or "笔记文档"
|
||||||
|
doc_url: str = doc.get("url") or ""
|
||||||
|
for chunk in doc.get("chunks") or []:
|
||||||
|
chunk_content: str = (chunk.get("content") or "").strip()
|
||||||
|
if not chunk_content:
|
||||||
|
continue
|
||||||
|
mat_idx += 1
|
||||||
|
results.append(Material(
|
||||||
|
id=f"nb{mat_idx:03d}",
|
||||||
|
title=doc_title,
|
||||||
|
content=chunk_content[:500],
|
||||||
|
source=f"笔记·{doc_title}",
|
||||||
|
source_type="notebook",
|
||||||
|
relevance_score=1.0,
|
||||||
|
url=doc_url,
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("notebook search kw=%r failed: %s", kw, e)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("notebook search client error: %s", e)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||||
intent = state["user_intent"]
|
intent = state["user_intent"]
|
||||||
article_type = state.get("article_type", "analysis")
|
article_type = state.get("article_type", "analysis")
|
||||||
@@ -167,6 +231,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
|||||||
if extra_keywords:
|
if extra_keywords:
|
||||||
keywords = list(dict.fromkeys(keywords + extra_keywords))
|
keywords = list(dict.fromkeys(keywords + extra_keywords))
|
||||||
|
|
||||||
|
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
|
||||||
|
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
|
||||||
|
notebook_materials: list[Material] = []
|
||||||
|
if notebook_search_space_id:
|
||||||
|
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
|
||||||
|
notebook_token: str = cfg.get("notebook_token") or ""
|
||||||
|
_emit("正在检索笔记内容…")
|
||||||
|
notebook_materials = await _search_notebook(
|
||||||
|
keywords[:_NOTEBOOK_MAX_KEYWORDS],
|
||||||
|
notebook_search_space_id,
|
||||||
|
notebook_source_ids,
|
||||||
|
notebook_token,
|
||||||
|
)
|
||||||
|
if notebook_materials:
|
||||||
|
_emit(f"从笔记中获取到 {len(notebook_materials)} 条素材")
|
||||||
|
|
||||||
# Step 2: 搜索
|
# Step 2: 搜索
|
||||||
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
|
# 技能检索:技能不是素材,是"检索策略 / 视角"。流程:
|
||||||
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
|
# a. SkillPicker 选出按相关度排序的 skills(推 list_skills + select_skills 事件)
|
||||||
@@ -302,10 +382,17 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
|||||||
existing_pkg = state.get("material_package")
|
existing_pkg = state.get("material_package")
|
||||||
if is_skill_search:
|
if is_skill_search:
|
||||||
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
|
# 技能编排循环已经完成素材累积,直接用 raw_materials_pre 当 raw_materials
|
||||||
|
# 笔记素材追加在技能素材之后
|
||||||
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
|
raw_materials: list[Material] = raw_materials_pre # type: ignore[name-defined]
|
||||||
|
raw_materials.extend(notebook_materials)
|
||||||
else:
|
else:
|
||||||
seen_urls: set[str] = set()
|
seen_urls: set[str] = set()
|
||||||
raw_materials = []
|
# 笔记素材优先排在最前面,赋予高相关度
|
||||||
|
raw_materials = list(notebook_materials)
|
||||||
|
for m in notebook_materials:
|
||||||
|
if m.get("url"):
|
||||||
|
seen_urls.add(m["url"])
|
||||||
|
|
||||||
preserve_existing = existing_pkg and (is_supplement or is_append_only)
|
preserve_existing = existing_pkg and (is_supplement or is_append_only)
|
||||||
if preserve_existing:
|
if preserve_existing:
|
||||||
for m in existing_pkg.get("materials", []):
|
for m in existing_pkg.get("materials", []):
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ class AIWritingState(TypedDict):
|
|||||||
keyword_count: int
|
keyword_count: int
|
||||||
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
|
# 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose)
|
||||||
writing_mode: str
|
writing_mode: str
|
||||||
|
# 笔记来源(可选):传入后 researcher 阶段会额外从笔记检索素材
|
||||||
|
notebook_search_space_id: Optional[int]
|
||||||
|
notebook_source_ids: Optional[List[int]]
|
||||||
|
|
||||||
# Agent A 输出
|
# Agent A 输出
|
||||||
material_package: Optional[MaterialPackage]
|
material_package: Optional[MaterialPackage]
|
||||||
|
|||||||
Reference in new issue
Block a user