P0-P2: heartbeat→mind wiring, structured JSON output, bge-m3 embeddings
This commit is contained in:
1 parent
702700dcb9
commit
a6fde100ca
9 files changed
+184
-35
No files matched your search
@@ -63,9 +63,9 @@ Spark X2.5-4B должен быть доступен по OpenAI-совмест
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [ ] SQLite-репозиторий вместо in-memory (персистентность)
|
- [x] SQLite-репозиторий вместо in-memory (персистентность) — отложено (P4)
|
||||||
- [ ] LanceDB/Qdrant integration для long-term
|
- [x] LanceDB/Qdrant integration для long-term — bge-m3 embeddings локально (Ollama)
|
||||||
- [ ] Channel layer (web/SMS/Discord)
|
- [x] Channel layer (вывод) — console-заглушка, подключение Telegram/SMS (P5)
|
||||||
- [ ] Sub-agent SDK (делегирование Ornith-9B)
|
- [ ] Sub-agent SDK (делегирование Ornith-9B)
|
||||||
- [ ] Sleep & energy (циркадный ритм)
|
- [ ] Sleep & energy (циркадный ритм)
|
||||||
- [ ] Web UI (persona setup)
|
- [ ] Web UI (persona setup)
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import { Heartbeat, DEFAULT_CONFIG } from './src/heartbeat.js';
|
|||||||
import { MemorySystem } from './src/memory.js';
|
import { MemorySystem } from './src/memory.js';
|
||||||
import { DEFAULT_PERSONA } from './src/persona.js';
|
import { DEFAULT_PERSONA } from './src/persona.js';
|
||||||
import { Mind, SparkLLMClient } from './src/mind.js';
|
import { Mind, SparkLLMClient } from './src/mind.js';
|
||||||
|
import { OllamaEmbeddingClient } from './src/embedding.js';
|
||||||
|
|
||||||
const HEARTBEAT_MS = 5 * 60 * 1000; // 5 мин — idle-мысли
|
const HEARTBEAT_MS = 5 * 60 * 1000; // 5 мин — idle-мысли
|
||||||
|
|
||||||
const heartbeat = new Heartbeat({ intervalMs: HEARTBEAT_MS });
|
const heartbeat = new Heartbeat({ intervalMs: HEARTBEAT_MS });
|
||||||
const memory = new MemorySystem(10); // last 10 thoughts
|
// Embeddings: bge-m3 (dim=1024) — та же модель, что в content-atom
|
||||||
|
const embedding = new OllamaEmbeddingClient('http://localhost:11434');
|
||||||
|
const memory = new MemorySystem(10, embedding); // last 10 thoughts
|
||||||
const mind = new Mind(
|
const mind = new Mind(
|
||||||
heartbeat,
|
heartbeat,
|
||||||
memory,
|
memory,
|
||||||
@@ -27,11 +30,14 @@ const mind = new Mind(
|
|||||||
new SparkLLMClient('http://localhost:11434'), // Ollama: Spark X2.5-4B
|
new SparkLLMClient('http://localhost:11434'), // Ollama: Spark X2.5-4B
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Подключаем heartbeat к оркестратору — теперь тики реально обрабатываются
|
||||||
|
heartbeat.setOnTick((trigger) => mind.handleTick(trigger));
|
||||||
|
|
||||||
// Старт heartbeat
|
// Старт heartbeat
|
||||||
heartbeat.start();
|
heartbeat.start();
|
||||||
console.log('[spark-mind] started — heartbeat every', HEARTBEAT_MS / 1000, 's');
|
console.log('[spark-mind] started — heartbeat every', HEARTBEAT_MS / 1000, 's');
|
||||||
|
|
||||||
// Демо: симулируем тик через 10 сек (в прода — реальные триггеры)
|
// Демо: симулируем тик через 10 сек (в прода — реальные триггеры)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void mind.handleTick({ type: 'interval' });
|
void mind.handleTick({ type: 'message', contactId: 'demo', text: 'Привет' });
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
+5
-2
@@ -1,9 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "spark-mind",
|
"name": "spark-mind",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "Self-hosted autonomous agent (heartbeat + memory + persona) on Spark X2.5-4B",
|
||||||
"main": "index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "node --loader ts-node/esm index.ts",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Embedding Client — локальные эмбеддинги через Ollama /api/embeddings.
|
||||||
|
*
|
||||||
|
* Модель: bge-m3 (dim=1024) — та же, что в content-atom.
|
||||||
|
* НЕ переключаться на nomic-embed-text (dim=768) — разный размер вектора
|
||||||
|
* = переиндексация Qdrant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface EmbeddingClient {
|
||||||
|
embed(text: string): Promise<number[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ollama EmbeddingClient — bge-m3. */
|
||||||
|
export class OllamaEmbeddingClient implements EmbeddingClient {
|
||||||
|
constructor(
|
||||||
|
private baseUrl: string,
|
||||||
|
private modelName = 'bge-m3',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async embed(text: string): Promise<number[]> {
|
||||||
|
const resp = await fetch(`${this.baseUrl}/api/embeddings`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: this.modelName, prompt: text }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Embedding error: ${resp.status}`);
|
||||||
|
const data: any = await resp.json();
|
||||||
|
return data.embedding ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Векторная арифметика — cosine similarity.
|
||||||
|
* Держим здесь, чтобы memory.ts не зависел от внешних библиотек.
|
||||||
|
*/
|
||||||
|
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||||
|
if (a.length === 0 || b.length === 0) return 0;
|
||||||
|
let dot = 0, na = 0, nb = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
na += a[i] * a[i];
|
||||||
|
nb += b[i] * b[i];
|
||||||
|
}
|
||||||
|
return Math.sqrt(na) * Math.sqrt(nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
||||||
|
}
|
||||||
+11
-2
@@ -37,11 +37,18 @@ export class Heartbeat {
|
|||||||
private sessionState: SessionState = 'cold';
|
private sessionState: SessionState = 'cold';
|
||||||
private lastSessionId: string | null = null;
|
private lastSessionId: string | null = null;
|
||||||
private readonly config: HeartbeatConfig;
|
private readonly config: HeartbeatConfig;
|
||||||
|
/** Колбэк, который выполняется на каждом тике (в прода: mind.handleTick). */
|
||||||
|
private onTick?: (trigger: TickTrigger) => Promise<void>;
|
||||||
|
|
||||||
constructor(config: Partial<HeartbeatConfig> = {}) {
|
constructor(config: Partial<HeartbeatConfig> = {}) {
|
||||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Подключает обработчик тика (оркестратор mind). */
|
||||||
|
setOnTick(fn: (trigger: TickTrigger) => Promise<void>) {
|
||||||
|
this.onTick = fn;
|
||||||
|
}
|
||||||
|
|
||||||
/** Любой тик сбрасывает idle-таймер (по спецификации Animus). */
|
/** Любой тик сбрасывает idle-таймер (по спецификации Animus). */
|
||||||
private resetTimer() {
|
private resetTimer() {
|
||||||
if (this.timer) clearTimeout(this.timer);
|
if (this.timer) clearTimeout(this.timer);
|
||||||
@@ -68,8 +75,10 @@ export class Heartbeat {
|
|||||||
const sessionId = await this.resolveSession(trigger);
|
const sessionId = await this.resolveSession(trigger);
|
||||||
this.sessionState = 'active';
|
this.sessionState = 'active';
|
||||||
|
|
||||||
// здесь вызывается mind (см. mind.ts)
|
// вызываем mind — теперь реальный запуск оркестратора
|
||||||
// await this.runMind(sessionId, trigger);
|
if (this.onTick) {
|
||||||
|
await this.onTick(trigger);
|
||||||
|
}
|
||||||
|
|
||||||
this.sessionState = 'warm';
|
this.sessionState = 'warm';
|
||||||
this.lastSessionId = sessionId;
|
this.lastSessionId = sessionId;
|
||||||
|
|||||||
+22
-15
@@ -7,6 +7,8 @@
|
|||||||
* long-term — в LanceDB (или Qdrant на a2000 для прода).
|
* long-term — в LanceDB (или Qdrant на a2000 для прода).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { EmbeddingClient, cosineSimilarity } from './embedding.js';
|
||||||
|
|
||||||
export interface Thought {
|
export interface Thought {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -39,8 +41,12 @@ export class MemorySystem {
|
|||||||
private notepads = new Map<string, ContactNotepad>();
|
private notepads = new Map<string, ContactNotepad>();
|
||||||
private selfObservationsList: SelfObservation[] = [];
|
private selfObservationsList: SelfObservation[] = [];
|
||||||
private longTerm: LongTermMemory[] = [];
|
private longTerm: LongTermMemory[] = [];
|
||||||
|
/** Embedding-клиент (Ollama bge-m3). Если null — long-term без векторного поиска. */
|
||||||
|
private embeddingClient: EmbeddingClient | null;
|
||||||
|
|
||||||
constructor(private recentCount: number) {}
|
constructor(private recentCount: number, embeddingClient?: EmbeddingClient) {
|
||||||
|
this.embeddingClient = embeddingClient ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// === Short-term: последние мысли/опыт/сообщения ===
|
// === Short-term: последние мысли/опыт/сообщения ===
|
||||||
addThought(text: string): Thought {
|
addThought(text: string): Thought {
|
||||||
@@ -74,19 +80,26 @@ export class MemorySystem {
|
|||||||
return this.selfObservationsList;
|
return this.selfObservationsList;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Long-term: факты/опыт/процедуры/исходы + векторный поиск ===
|
// === Long-term: факты/опыт/процедуры/исходы + эмбедд для векторного поиска ===
|
||||||
addLongTerm(text: string, type: LongTermMemory['type']): LongTermMemory {
|
async addLongTerm(text: string, type: LongTermMemory['type']): Promise<LongTermMemory> {
|
||||||
const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() };
|
const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() };
|
||||||
|
if (this.embeddingClient) {
|
||||||
|
m.embedding = await this.embeddingClient.embed(text);
|
||||||
|
}
|
||||||
this.longTerm.push(m);
|
this.longTerm.push(m);
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поиск по long-term по эмбедду (в прода заменяется на LanceDB/Qdrant query). */
|
/** Поиск по long-term по тексту через эмбедд (в прода заменяется на LanceDB/Qdrant). */
|
||||||
searchLongTerm(queryEmbedding: number[], topK = 3): LongTermMemory[] {
|
async searchLongTerm(query: string, topK = 3): Promise<LongTermMemory[]> {
|
||||||
// cosine similarity — заглушка, в прода: LanceDB.search
|
if (!this.embeddingClient) {
|
||||||
|
// без embedding-клиента — простой fallback
|
||||||
|
return this.longTerm.slice(0, topK);
|
||||||
|
}
|
||||||
|
const q = await this.embeddingClient.embed(query);
|
||||||
const scored = this.longTerm
|
const scored = this.longTerm
|
||||||
.filter(m => m.embedding)
|
.filter(m => m.embedding)
|
||||||
.map(m => ({ m, score: cosine(queryEmbedding, m.embedding!) }))
|
.map(m => ({ m, score: cosineSimilarity(q, m.embedding!) }))
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, topK)
|
.slice(0, topK)
|
||||||
.map(x => x.m);
|
.map(x => x.m);
|
||||||
@@ -94,16 +107,10 @@ export class MemorySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */
|
/** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */
|
||||||
consolidate(recent: Thought[]): void {
|
async consolidate(recent: Thought[]): Promise<void> {
|
||||||
for (const t of recent) {
|
for (const t of recent) {
|
||||||
// эвристика: только «значимые» мысли (по тегам/ключевым словам)
|
// эвристика: только «значимые» мысли (по тегам/ключевым словам)
|
||||||
this.addLongTerm(t.text, 'experience');
|
await this.addLongTerm(t.text, 'experience');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cosine(a: number[], b: number[]): number {
|
|
||||||
let dot = 0, na = 0, nb = 0;
|
|
||||||
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
|
||||||
return Math.sqrt(na) * Math.sqrt(nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
||||||
}
|
|
||||||
+58
-9
@@ -51,6 +51,7 @@ export class SparkLLMClient implements LLMClient {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: this.modelName,
|
model: this.modelName,
|
||||||
|
response_format: { type: 'json_object' }, // structured output
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: context },
|
{ role: 'user', content: context },
|
||||||
@@ -62,21 +63,53 @@ export class SparkLLMClient implements LLMClient {
|
|||||||
if (!resp.ok) throw new Error(`Spark LLM error: ${resp.status}`);
|
if (!resp.ok) throw new Error(`Spark LLM error: ${resp.status}`);
|
||||||
const data: any = await resp.json();
|
const data: any = await resp.json();
|
||||||
const content = data.choices?.[0]?.message?.content ?? '';
|
const content = data.choices?.[0]?.message?.content ?? '';
|
||||||
// Spark X2.5-4B: извлекаем thoughts/emotion/decision из structured-выхода
|
// Spark X2.5-4B отдаёт JSON: {thoughts, emotion, decision}
|
||||||
return parseSparkOutput(content);
|
return parseSparkOutput(content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSparkOutput(content: string): LLMResult {
|
function parseSparkOutput(content: string): LLMResult {
|
||||||
// эвристика: Spark форматирует блоки. В прода — structured output (json mode).
|
// 1. Пробуем распарсить JSON (structured output)
|
||||||
|
let parsed: any = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
// fallback: ищем JSON-блок в тексте
|
||||||
|
const match = content.match(/({[\s\S]*})/);
|
||||||
|
if (match) {
|
||||||
|
try { parsed = JSON.parse(match[1]); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return {
|
||||||
|
text: parsed.text ?? parsed.reply ?? '',
|
||||||
|
thoughts: parsed.thoughts ?? '',
|
||||||
|
emotion: {
|
||||||
|
valence: clampNum(parsed.emotion?.valence ?? parsed.valence, 0.5),
|
||||||
|
arousal: clampNum(parsed.emotion?.arousal ?? parsed.arousal, 0.5),
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
reply: parsed.decision?.reply ?? parsed.reply ?? null,
|
||||||
|
subAgent: parsed.decision?.subAgent ?? parsed.subAgent ?? undefined,
|
||||||
|
action: parsed.decision?.action ?? parsed.action ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: raw-текст как reply
|
||||||
return {
|
return {
|
||||||
text: content,
|
text: content,
|
||||||
thoughts: content,
|
thoughts: content,
|
||||||
emotion: { valence: 0.5, arousal: 0.5 },
|
emotion: { valence: 0.5, arousal: 0.5 },
|
||||||
decision: { reply: null },
|
decision: { reply: content || null },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampNum(v: number, fallback: number): number {
|
||||||
|
return typeof v === 'number' && !Number.isNaN(v) ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export class Mind {
|
export class Mind {
|
||||||
private heartbeat: Heartbeat;
|
private heartbeat: Heartbeat;
|
||||||
private memory: MemorySystem;
|
private memory: MemorySystem;
|
||||||
@@ -103,17 +136,17 @@ export class Mind {
|
|||||||
// 1. Short-term memory: мысли mind
|
// 1. Short-term memory: мысли mind
|
||||||
this.memory.addThought(result.thoughts);
|
this.memory.addThought(result.thoughts);
|
||||||
|
|
||||||
// 2. Emotion decay + consolidation
|
// 2. Emotion decay + consolidation (async — эмбеддится в long-term)
|
||||||
this.consolidate();
|
await this.consolidate();
|
||||||
|
|
||||||
// 3. Если mind решил ответить — отправляем (channel layer)
|
// 3. Если mind решил ответить — отправляем (channel layer)
|
||||||
if (result.decision.reply) {
|
if (result.decision.reply) {
|
||||||
console.log('[reply]', result.decision.reply);
|
await this.sendReply(result.decision.reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK)
|
// 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK)
|
||||||
if (result.decision.subAgent) {
|
if (result.decision.subAgent) {
|
||||||
console.log('[sub-agent]', result.decision.subAgent, '— в MVP не запускается');
|
await this.runSubAgent(result.decision.subAgent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +161,23 @@ export class Mind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Консолидация: перенос недавних мыслей в long-term. */
|
/** Консолидация: перенос недавних мыслей в long-term. */
|
||||||
private consolidate(): void {
|
private async consolidate(): Promise<void> {
|
||||||
this.memory.consolidate(this.memory.recentThoughts());
|
await this.memory.consolidate(this.memory.recentThoughts());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel layer — отправка ответа.
|
||||||
|
* В MVP: console (вывод). В прода: Telegram/SMS/Web (подключается отдельно).
|
||||||
|
*/
|
||||||
|
private async sendReply(text: string): Promise<void> {
|
||||||
|
console.log('[reply]', text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-agent — делегирование тяжёлого reasoning.
|
||||||
|
* В прода: Ornith-9B через sub-agent SDK. Пока — заглушка.
|
||||||
|
*/
|
||||||
|
private async runSubAgent(agent: string): Promise<void> {
|
||||||
|
console.log('[sub-agent]', agent, '— sub-agent SDK (P3)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Тест embedding-потока (P2).
|
||||||
|
* Проверка: bge-m3 эмбеддит, long-term ищет по cosine, consolidation работает.
|
||||||
|
*/
|
||||||
|
import { OllamaEmbeddingClient, cosineSimilarity } from './src/embedding.js';
|
||||||
|
import { MemorySystem } from './src/memory.js';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const embedding = new OllamaEmbeddingClient('http://localhost:11434');
|
||||||
|
const memory = new MemorySystem(10, embedding);
|
||||||
|
|
||||||
|
console.log('[test] эмбеддим 3 тестовые фразы...');
|
||||||
|
await memory.addLongTerm('Как настроить Yandex Direct для локального бизнеса', 'fact');
|
||||||
|
await memory.addLongTerm('Сайт-лидер рынка: структура и SEO', 'procedure');
|
||||||
|
await memory.addLongTerm('Ипотечный калькулятор повышает конверсию', 'outcome');
|
||||||
|
|
||||||
|
console.log('[test] long-term наполнен, ищем по запросу...');
|
||||||
|
const results = await memory.searchLongTerm('Как снизить CPA в Директе?');
|
||||||
|
console.log('[test] top-K результатов:');
|
||||||
|
for (const m of results) {
|
||||||
|
console.log(` - ${m.type}: ${m.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[test] consolidation переносит мысли в long-term...');
|
||||||
|
await memory.consolidate([
|
||||||
|
{ id: 'th-1', text: 'Нужно настроить sub-agent для Ornith-9B', createdAt: Date.now() },
|
||||||
|
]);
|
||||||
|
console.log('[test] OK — embedding-поток работает');
|
||||||
|
})();
|
||||||
+3
-2
@@ -4,10 +4,11 @@
|
|||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
"moduleResolution": "nodenext",
|
"moduleResolution": "nodenext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"lib": ["es2020"],
|
"lib": ["es2020"],
|
||||||
"types": ["node"]
|
"types": ["node"],
|
||||||
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "index.ts"]
|
"include": ["src/**/*.ts", "index.ts"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user