mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(context): consume compact_status on the frontend; load history from latest boundary; backfill summaryId
This commit is contained in:
parent
51b5eceb7d
commit
faf7f98358
@ -235,12 +235,17 @@ public abstract class BaseAgent {
|
|||||||
agentName, history.size(), totalCount, windowSize);
|
agentName, history.size(), totalCount, windowSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 识别持久化的压缩摘要:从摘要位置开始,跳过更早消息 =====
|
// ===== Slice from the LATEST compression boundary, not the first =====
|
||||||
for (int i = 0; i < history.size(); i++) {
|
// A long-running conversation can accumulate several boundaries; the
|
||||||
|
// newest one is the only relevant cut-off because every earlier
|
||||||
|
// boundary's content is already folded into the newer summary. Walking
|
||||||
|
// forward and breaking on the first boundary kept everything between
|
||||||
|
// boundaries — the very redundancy compaction was supposed to remove.
|
||||||
|
for (int i = history.size() - 1; i >= 0; i--) {
|
||||||
MessageEntity msg = history.get(i);
|
MessageEntity msg = history.get(i);
|
||||||
if ("system".equals(msg.getRole()) && isCompressionSummary(msg)) {
|
if ("system".equals(msg.getRole()) && isCompressionSummary(msg)) {
|
||||||
history = new ArrayList<>(history.subList(i, history.size()));
|
history = new ArrayList<>(history.subList(i, history.size()));
|
||||||
log.info("[{}] Found compression summary, loading from index {} ({} messages)",
|
log.info("[{}] Found latest compression boundary at index {}; loading {} messages forward",
|
||||||
agentName, i, history.size());
|
agentName, i, history.size());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -477,6 +477,11 @@ public class ConversationService {
|
|||||||
if (v != null) metadata.put(k, v);
|
if (v != null) metadata.put(k, v);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// First write a placeholder so the row lands with the structured
|
||||||
|
// fields; we backfill summaryId in a second step once MyBatis Plus
|
||||||
|
// has assigned the snowflake id. ASSIGN_ID actually populates the
|
||||||
|
// id BEFORE flushing the INSERT, but reading it back this way means
|
||||||
|
// the contract holds even if the ID generation strategy changes.
|
||||||
try {
|
try {
|
||||||
entity.setMetadata(objectMapper.writeValueAsString(metadata));
|
entity.setMetadata(objectMapper.writeValueAsString(metadata));
|
||||||
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
|
||||||
@ -485,6 +490,20 @@ public class ConversationService {
|
|||||||
entity.setMetadata("{\"type\":\"compression_summary\",\"compressedCount\":" + compressedCount + "}");
|
entity.setMetadata("{\"type\":\"compression_summary\",\"compressedCount\":" + compressedCount + "}");
|
||||||
}
|
}
|
||||||
messageMapper.insert(entity);
|
messageMapper.insert(entity);
|
||||||
|
|
||||||
|
// Backfill summaryId now that the row owns an id. Best-effort: a
|
||||||
|
// failure here doesn't invalidate the boundary itself, it just
|
||||||
|
// means SSE clients won't have a deep-link target for this row.
|
||||||
|
if (entity.getId() != null) {
|
||||||
|
metadata.put("summaryId", entity.getId());
|
||||||
|
try {
|
||||||
|
entity.setMetadata(objectMapper.writeValueAsString(metadata));
|
||||||
|
messageMapper.updateById(entity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[Conversation] Failed to backfill summaryId on compression boundary: {}",
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
log.info("[Conversation] Saved compression boundary conv={}, compressedCount={}, metadata={}",
|
log.info("[Conversation] Saved compression boundary conv={}, compressedCount={}, metadata={}",
|
||||||
conversationId, compressedCount, entity.getMetadata());
|
conversationId, compressedCount, entity.getMetadata());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,28 @@ interface LifecycleStage {
|
|||||||
since: number
|
since: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Latest compact_status SSE event from useChat. Renders an inline chip
|
||||||
|
* so the user can see that the pause is the window manager compacting
|
||||||
|
* history, not a network stall. */
|
||||||
|
interface CompactStatus {
|
||||||
|
status: 'start' | 'pair_safe' | 'summarize' | 'done' | 'skipped' | 'failed'
|
||||||
|
preTokens?: number
|
||||||
|
postTokens?: number
|
||||||
|
messagesIn?: number
|
||||||
|
messagesSummarized?: number
|
||||||
|
tailKept?: number
|
||||||
|
toolResultsSpilled?: number
|
||||||
|
reason?: string
|
||||||
|
anchored?: boolean
|
||||||
|
fromCache?: boolean
|
||||||
|
movedFrom?: number
|
||||||
|
movedTo?: number
|
||||||
|
summaryBudget?: number
|
||||||
|
trigger?: string
|
||||||
|
fallbackKept?: number
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
toolCount?: number
|
toolCount?: number
|
||||||
@ -53,6 +75,8 @@ interface Props {
|
|||||||
hasQueued?: boolean
|
hasQueued?: boolean
|
||||||
/** Fine-grained pre-token stage. Preferred over `phase` while no token has arrived. */
|
/** Fine-grained pre-token stage. Preferred over `phase` while no token has arrived. */
|
||||||
lifecycleStage?: LifecycleStage | null
|
lifecycleStage?: LifecycleStage | null
|
||||||
|
/** Latest compact_status event. When non-null and not 'done', the bar shows compaction copy. */
|
||||||
|
compactStatus?: CompactStatus | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -66,6 +90,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
runningToolName: '',
|
runningToolName: '',
|
||||||
hasQueued: false,
|
hasQueued: false,
|
||||||
lifecycleStage: null,
|
lifecycleStage: null,
|
||||||
|
compactStatus: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -119,7 +144,51 @@ const inPreTokenWindow = computed(() => {
|
|||||||
return !!ls && ls.stage !== 'streaming'
|
return !!ls && ls.stage !== 'streaming'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compaction copy. Takes priority over both lifecycleStage and phase
|
||||||
|
* while the compactor is mid-pass (any status except done/skipped/failed)
|
||||||
|
* because the user cares more about "we paused to compact" than the
|
||||||
|
* underlying llm-request lifecycle. After done/skipped/failed we let the
|
||||||
|
* regular phase text take over — the chip's transient hint suffices.
|
||||||
|
*/
|
||||||
|
const compactStatusText = computed(() => {
|
||||||
|
const cs = props.compactStatus
|
||||||
|
if (!cs) return ''
|
||||||
|
switch (cs.status) {
|
||||||
|
case 'start':
|
||||||
|
return cs.preTokens
|
||||||
|
? t('chat.compactStartWithTokens', { tokens: formatTokens(cs.preTokens) })
|
||||||
|
: t('chat.compactStart')
|
||||||
|
case 'pair_safe':
|
||||||
|
return t('chat.compactPairSafe')
|
||||||
|
case 'summarize': {
|
||||||
|
const n = cs.messagesSummarized ?? cs.messagesIn ?? 0
|
||||||
|
return n > 0
|
||||||
|
? t('chat.compactSummarizeWithCount', { count: n })
|
||||||
|
: t('chat.compactSummarize')
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCompactActive = computed(() => {
|
||||||
|
const s = props.compactStatus?.status
|
||||||
|
return s === 'start' || s === 'pair_safe' || s === 'summarize'
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}k tokens`
|
||||||
|
return `${n} tokens`
|
||||||
|
}
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
|
// Compaction copy wins while a pass is in flight. Done/skipped/failed
|
||||||
|
// fall through to the regular phase text so the chip releases focus.
|
||||||
|
if (isCompactActive.value) {
|
||||||
|
const cText = compactStatusText.value
|
||||||
|
if (cText) return cText
|
||||||
|
}
|
||||||
// Prefer fine-grained pre-token text when no first delta has arrived yet.
|
// Prefer fine-grained pre-token text when no first delta has arrived yet.
|
||||||
if (inPreTokenWindow.value && props.lifecycleStage) {
|
if (inPreTokenWindow.value && props.lifecycleStage) {
|
||||||
const key = lifecycleI18nMap[props.lifecycleStage.stage]
|
const key = lifecycleI18nMap[props.lifecycleStage.stage]
|
||||||
|
|||||||
@ -17,6 +17,45 @@ import type { Message, MessageContentPart, MessageSegment, StreamPhase, Heartbea
|
|||||||
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
|
import { classifyBackendError, type ChatErrorInfo } from '@/types/chatError'
|
||||||
import { http } from '@/api'
|
import { http } from '@/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of a {@code compact_status} SSE event. Mirrors the payload built
|
||||||
|
* by ConversationWindowManager.broadcastCompactStatus so the UI can render a
|
||||||
|
* progress chip without each consumer reverse-engineering field names.
|
||||||
|
*/
|
||||||
|
export interface CompactStatusEvent {
|
||||||
|
/** start | pair_safe | summarize | done | skipped | failed */
|
||||||
|
status: 'start' | 'pair_safe' | 'summarize' | 'done' | 'skipped' | 'failed'
|
||||||
|
/** Server clock when the event fired. */
|
||||||
|
timestamp?: number
|
||||||
|
/** Total prompt tokens before compaction began (start / done payloads). */
|
||||||
|
preTokens?: number
|
||||||
|
/** Total prompt tokens after the boundary lands (done payload). */
|
||||||
|
postTokens?: number
|
||||||
|
/** Messages in scope at start. */
|
||||||
|
messagesIn?: number
|
||||||
|
/** Messages folded into the structured summary (done payload). */
|
||||||
|
messagesSummarized?: number
|
||||||
|
/** Recent messages preserved verbatim (done payload). */
|
||||||
|
tailKept?: number
|
||||||
|
/** Tool-result bodies spilled to disk this turn (done payload). */
|
||||||
|
toolResultsSpilled?: number
|
||||||
|
/** Whether the first-user anchor was injected (done payload). */
|
||||||
|
anchored?: boolean
|
||||||
|
/** Why compaction was skipped or failed: insufficient_messages, pair_boundary_collapsed, summary_generation_failed, ... */
|
||||||
|
reason?: string
|
||||||
|
/** Pair-safety boundary moved from / to indices (pair_safe payload). */
|
||||||
|
movedFrom?: number
|
||||||
|
movedTo?: number
|
||||||
|
/** Summary budget the LLM was asked to fit into (summarize payload). */
|
||||||
|
summaryBudget?: number
|
||||||
|
/** Trigger label baked in by the backend (start / done — currently token_threshold). */
|
||||||
|
trigger?: string
|
||||||
|
/** Tail kept fallback when summary generation failed. */
|
||||||
|
fallbackKept?: number
|
||||||
|
/** True when the boundary was served from the in-memory summary cache. */
|
||||||
|
fromCache?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseChatOptions {
|
export interface UseChatOptions {
|
||||||
/** Base API URL */
|
/** Base API URL */
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@ -62,6 +101,13 @@ export interface UseChatReturn {
|
|||||||
queueSize: import('vue').ComputedRef<number>
|
queueSize: import('vue').ComputedRef<number>
|
||||||
/** Latest heartbeat data */
|
/** Latest heartbeat data */
|
||||||
heartbeat: import('vue').Ref<HeartbeatData | null>
|
heartbeat: import('vue').Ref<HeartbeatData | null>
|
||||||
|
/**
|
||||||
|
* Latest compact_status SSE event for the active turn. Drives the in-prompt
|
||||||
|
* compaction chip / boundary marker so the user can see "preparing context"
|
||||||
|
* pauses (start → pair_safe → summarize → done/skipped/failed). Cleared back
|
||||||
|
* to {@code null} when a turn finishes, so the chip auto-hides.
|
||||||
|
*/
|
||||||
|
compactStatus: import('vue').Ref<CompactStatusEvent | null>
|
||||||
/**
|
/**
|
||||||
* Fine-grained pre-token lifecycle stage. Drives the loading bar copy in the
|
* Fine-grained pre-token lifecycle stage. Drives the loading bar copy in the
|
||||||
* window between "send pressed" and "first delta arrived". `null` once a
|
* window between "send pressed" and "first delta arrived". `null` once a
|
||||||
@ -127,6 +173,14 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
let stopFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const streamPhase = ref<StreamPhase>('idle')
|
const streamPhase = ref<StreamPhase>('idle')
|
||||||
const phaseInfo = ref<PhaseEventData | null>(null)
|
const phaseInfo = ref<PhaseEventData | null>(null)
|
||||||
|
/**
|
||||||
|
* Latest compact_status event for the current turn. Reset to null on
|
||||||
|
* stream end and on every conversation switch so the chip auto-hides.
|
||||||
|
* "done" events are kept on screen for a short interval by the consumer
|
||||||
|
* (see StreamLoadingBar / CompactStatusBadge) rather than being cleared
|
||||||
|
* immediately, so the user gets a chance to see the result.
|
||||||
|
*/
|
||||||
|
const compactStatus = ref<CompactStatusEvent | null>(null)
|
||||||
|
|
||||||
/** All segments of the current assistant message (for segmented display) */
|
/** All segments of the current assistant message (for segmented display) */
|
||||||
const currentSegments = ref<MessageSegment[]>([])
|
const currentSegments = ref<MessageSegment[]>([])
|
||||||
@ -502,6 +556,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
: data.status === 'stopped' ? 'stopped' : 'completed'
|
: data.status === 'stopped' ? 'stopped' : 'completed'
|
||||||
if (data.status !== 'awaiting_approval') {
|
if (data.status !== 'awaiting_approval') {
|
||||||
phaseInfo.value = null
|
phaseInfo.value = null
|
||||||
|
compactStatus.value = null
|
||||||
lifecycleStage.value = null
|
lifecycleStage.value = null
|
||||||
expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed')
|
expirePendingApprovals(data.status === 'stopped' ? 'stopped' : 'completed')
|
||||||
}
|
}
|
||||||
@ -584,6 +639,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
error.value = new Error(errorMessage)
|
error.value = new Error(errorMessage)
|
||||||
streamPhase.value = 'idle'
|
streamPhase.value = 'idle'
|
||||||
phaseInfo.value = null
|
phaseInfo.value = null
|
||||||
|
compactStatus.value = null
|
||||||
lifecycleStage.value = null
|
lifecycleStage.value = null
|
||||||
// Clear queue on error to avoid stale state
|
// Clear queue on error to avoid stale state
|
||||||
messageQueue.clear()
|
messageQueue.clear()
|
||||||
@ -756,6 +812,15 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
} as any)
|
} as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Context-compaction progress. Fires before the LLM call when the window
|
||||||
|
// manager has to evict old turns to fit budget. The chip uses this to show
|
||||||
|
// the user that an unexpected pause is the planner thinking about
|
||||||
|
// context, not a network stall.
|
||||||
|
stream.on('compact_status', (data) => {
|
||||||
|
if (isStaleEvent(data)) return
|
||||||
|
compactStatus.value = { ...data } as CompactStatusEvent
|
||||||
|
})
|
||||||
|
|
||||||
stream.on('phase', (data) => {
|
stream.on('phase', (data) => {
|
||||||
if (isStaleEvent(data)) return
|
if (isStaleEvent(data)) return
|
||||||
const phase = data.phase as StreamPhase
|
const phase = data.phase as StreamPhase
|
||||||
@ -1645,6 +1710,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
// Mark as stopped immediately so the UI gives instant feedback
|
// Mark as stopped immediately so the UI gives instant feedback
|
||||||
streamPhase.value = 'stopped'
|
streamPhase.value = 'stopped'
|
||||||
phaseInfo.value = null
|
phaseInfo.value = null
|
||||||
|
compactStatus.value = null
|
||||||
|
|
||||||
// Install fallback timer before any await so it is not missed by a concurrent resetForNewConversation
|
// Install fallback timer before any await so it is not missed by a concurrent resetForNewConversation
|
||||||
if (stopFallbackTimer) clearTimeout(stopFallbackTimer)
|
if (stopFallbackTimer) clearTimeout(stopFallbackTimer)
|
||||||
@ -1792,6 +1858,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
segIdCounter.value = 0
|
segIdCounter.value = 0
|
||||||
streamPhase.value = 'idle'
|
streamPhase.value = 'idle'
|
||||||
phaseInfo.value = null
|
phaseInfo.value = null
|
||||||
|
compactStatus.value = null
|
||||||
lifecycleStage.value = null
|
lifecycleStage.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
messageQueue.clear()
|
messageQueue.clear()
|
||||||
@ -1811,6 +1878,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|||||||
hasQueued: messageQueue.hasQueued,
|
hasQueued: messageQueue.hasQueued,
|
||||||
queueSize: messageQueue.queueSize,
|
queueSize: messageQueue.queueSize,
|
||||||
heartbeat,
|
heartbeat,
|
||||||
|
compactStatus,
|
||||||
lifecycleStage,
|
lifecycleStage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
|
|||||||
@ -60,6 +60,11 @@ export type SSEEventType =
|
|||||||
| 'delegation_batch'
|
| 'delegation_batch'
|
||||||
// Recovery affordance for non-transient errors (ERROR_FALLBACK turns)
|
// Recovery affordance for non-transient errors (ERROR_FALLBACK turns)
|
||||||
| 'feedback_event'
|
| 'feedback_event'
|
||||||
|
// Context compaction lifecycle. Fired by ConversationWindowManager
|
||||||
|
// around each compaction pass so the UI can show a progress chip
|
||||||
|
// (start → pair_safe → summarize → done/skipped/failed). Payload
|
||||||
|
// carries preTokens/postTokens/messagesSummarized/tailKept/etc.
|
||||||
|
| 'compact_status'
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface SSEEvent {
|
||||||
type: SSEEventType
|
type: SSEEventType
|
||||||
|
|||||||
@ -297,6 +297,12 @@ export default {
|
|||||||
streamReconnecting: 'Reconnecting...',
|
streamReconnecting: 'Reconnecting...',
|
||||||
streamStopped: 'Stopped',
|
streamStopped: 'Stopped',
|
||||||
streamCompleted: 'Completed',
|
streamCompleted: 'Completed',
|
||||||
|
// Context compaction (compact_status SSE)
|
||||||
|
compactStart: 'Compacting context…',
|
||||||
|
compactPairSafe: 'Adjusting boundary to preserve tool_call pairs…',
|
||||||
|
compactSummarize: 'Summarizing the older turns…',
|
||||||
|
compactStartWithTokens: 'Compacting context… ({tokens})',
|
||||||
|
compactSummarizeWithCount: 'Summarizing the older turns… ({count} msgs)',
|
||||||
streamPreparingContextDetail: 'Collecting the current question, conversation history, and required context.',
|
streamPreparingContextDetail: 'Collecting the current question, conversation history, and required context.',
|
||||||
streamReadingMemoryDetail: 'Looking up the most relevant long-term memory and recent notes for this question.',
|
streamReadingMemoryDetail: 'Looking up the most relevant long-term memory and recent notes for this question.',
|
||||||
streamReasoningDetail: 'Comparing options and organizing the answer structure based on the available information.',
|
streamReasoningDetail: 'Comparing options and organizing the answer structure based on the available information.',
|
||||||
|
|||||||
@ -297,6 +297,12 @@ export default {
|
|||||||
streamReconnecting: '重新连接...',
|
streamReconnecting: '重新连接...',
|
||||||
streamStopped: '已停止',
|
streamStopped: '已停止',
|
||||||
streamCompleted: '已完成',
|
streamCompleted: '已完成',
|
||||||
|
// Context compaction (compact_status SSE)
|
||||||
|
compactStart: '正在压缩上下文…',
|
||||||
|
compactPairSafe: '调整边界以保留 tool_call 配对…',
|
||||||
|
compactSummarize: '正在生成压缩摘要…',
|
||||||
|
compactStartWithTokens: '正在压缩上下文… ({tokens})',
|
||||||
|
compactSummarizeWithCount: '正在生成压缩摘要… ({count} 条)',
|
||||||
streamPreparingContextDetail: '正在收集当前问题、历史对话和必要配置。',
|
streamPreparingContextDetail: '正在收集当前问题、历史对话和必要配置。',
|
||||||
streamReadingMemoryDetail: '正在查找与你当前问题最相关的长期记忆和今日记录。',
|
streamReadingMemoryDetail: '正在查找与你当前问题最相关的长期记忆和今日记录。',
|
||||||
streamReasoningDetail: '正在基于现有信息进行判断、比较方案并组织回答结构。',
|
streamReasoningDetail: '正在基于现有信息进行判断、比较方案并组织回答结构。',
|
||||||
|
|||||||
@ -284,6 +284,7 @@
|
|||||||
:running-tool-name="currentRunningToolName"
|
:running-tool-name="currentRunningToolName"
|
||||||
:has-queued="hasQueued"
|
:has-queued="hasQueued"
|
||||||
:lifecycle-stage="lifecycleStage"
|
:lifecycle-stage="lifecycleStage"
|
||||||
|
:compact-status="compactStatus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Multimodal routing hint: shown when pending attachments require a
|
<!-- Multimodal routing hint: shown when pending attachments require a
|
||||||
@ -664,6 +665,7 @@ const {
|
|||||||
hasQueued,
|
hasQueued,
|
||||||
queueSize,
|
queueSize,
|
||||||
heartbeat,
|
heartbeat,
|
||||||
|
compactStatus,
|
||||||
lifecycleStage,
|
lifecycleStage,
|
||||||
sendMessage: sendChatMessage,
|
sendMessage: sendChatMessage,
|
||||||
stopGeneration: stopChatGeneration,
|
stopGeneration: stopChatGeneration,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user