feat: enhance agent orchestration, knowledge flow and UI refinements
This commit is contained in:
108
frontend/src/pages/chat/ChatTopbarShortcuts.test.ts
Normal file
108
frontend/src/pages/chat/ChatTopbarShortcuts.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import ChatPage from './index.vue'
|
||||
import { navItems } from '@/app/navigation/nav'
|
||||
|
||||
vi.mock('@/pages/chat/composables/useChatView', async () => {
|
||||
const { ref } = await import('vue')
|
||||
|
||||
return {
|
||||
useChatView: () => ({
|
||||
store: {
|
||||
conversations: [],
|
||||
messages: [],
|
||||
currentConversationId: null,
|
||||
},
|
||||
inputMessage: ref(''),
|
||||
isSending: ref(false),
|
||||
chatContainer: ref(null),
|
||||
inputRef: ref(null),
|
||||
isTyping: ref(false),
|
||||
fileInputRef: ref(null),
|
||||
showEmojiPicker: ref(false),
|
||||
chatModels: ref([]),
|
||||
selectedModelName: ref(''),
|
||||
selectedModel: ref(null),
|
||||
isLoadingModels: ref(false),
|
||||
conversationsError: ref(''),
|
||||
orchestrationStatus: ref('idle'),
|
||||
orchestrationInsight: ref({ statusTitle: '', jarvisNote: '', details: [] }),
|
||||
activeAgent: ref(''),
|
||||
visitedAgents: ref([]),
|
||||
orchestrationEventFeed: ref([]),
|
||||
systemMeta: ref({
|
||||
systemName: '',
|
||||
systemVersion: '',
|
||||
uptimeSeconds: 0,
|
||||
gpuUtilPercent: null,
|
||||
gpuName: '',
|
||||
gpuMemoryUsedMb: null,
|
||||
gpuMemoryTotalMb: null,
|
||||
diskUsedGb: 0,
|
||||
diskTotalGb: 0,
|
||||
}),
|
||||
systemTelemetry: ref({
|
||||
cpu: { online: false, current: null, series: [] },
|
||||
memory: { online: false, current: null, series: [] },
|
||||
disk: { online: false, current: null, series: [] },
|
||||
gpu: { online: false, current: null, series: [] },
|
||||
network: {
|
||||
upload: { online: false, current: null, series: [] },
|
||||
download: { online: false, current: null, series: [] },
|
||||
},
|
||||
}),
|
||||
sessionTelemetry: ref({
|
||||
eventsCount: 0,
|
||||
toolCount: 0,
|
||||
agentCount: 0,
|
||||
activitySeries: [],
|
||||
}),
|
||||
sendMessage: vi.fn(),
|
||||
selectConversation: vi.fn(),
|
||||
newConversation: vi.fn(),
|
||||
deleteConversation: vi.fn(),
|
||||
formatTime: vi.fn(() => ''),
|
||||
formatConvDate: vi.fn(() => ''),
|
||||
autoResize: vi.fn(),
|
||||
handleFileSelect: vi.fn(),
|
||||
insertEmoji: vi.fn(),
|
||||
openFilePicker: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Chat topbar shortcuts', () => {
|
||||
it('replaces READY/heartbeat with shortcut icon row', async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: navItems.map((item) => ({
|
||||
path: item.path,
|
||||
name: item.path,
|
||||
component: { template: '<div />' },
|
||||
})),
|
||||
})
|
||||
|
||||
await router.push('/chat')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(ChatPage, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: {
|
||||
TelemetrySparkline: true,
|
||||
OrchestrationPanel: true,
|
||||
EmojiPicker: true,
|
||||
FileMessage: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.status-text').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('READY')
|
||||
expect(wrapper.find('[data-testid="nav-shortcut-row"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -130,11 +130,11 @@ describe('useChatView orchestration state', () => {
|
||||
handlers.onMetadata?.({ conversation_id: 'conv-1', message_id: 'msg-1' })
|
||||
handlers.onProgress?.({
|
||||
stage: 'planning',
|
||||
label: 'Jarvis 正在拆解步骤',
|
||||
agent: 'planner',
|
||||
label: 'Jarvis 正在编排日程',
|
||||
agent: 'schedule_planner',
|
||||
tool_name: null,
|
||||
step: '正在分配任务',
|
||||
steps: ['理解问题', '分配 planner'],
|
||||
step: '正在生成今日安排',
|
||||
steps: ['理解当前承诺', '分配 schedule_planner'],
|
||||
})
|
||||
handlers.onChunk?.({ content: '最终回复' })
|
||||
})
|
||||
@@ -150,9 +150,9 @@ describe('useChatView orchestration state', () => {
|
||||
expect(view.isTyping.value).toBe(true)
|
||||
expect(view.orchestrationPanelVisible.value).toBe(true)
|
||||
expect(view.orchestrationStatus.value).toBe('active')
|
||||
expect(view.activeAgent.value).toBe('planner')
|
||||
expect(view.visitedAgents.value).toContain('planner')
|
||||
expect(view.orchestrationEventFeed.value.map((item) => item.label)).toContain('正在分配任务')
|
||||
expect(view.activeAgent.value).toBe('schedule_planner')
|
||||
expect(view.visitedAgents.value).toContain('schedule_planner')
|
||||
expect(view.orchestrationEventFeed.value.flatMap((group) => group.items.map((item) => item.label))).toContain('正在生成今日安排')
|
||||
|
||||
await promise
|
||||
|
||||
@@ -161,8 +161,37 @@ describe('useChatView orchestration state', () => {
|
||||
expect(view.store.messages[1].content).toBe('最终回复')
|
||||
expect(view.orchestrationPanelVisible.value).toBe(true)
|
||||
expect(view.orchestrationStatus.value).toBe('complete')
|
||||
expect(view.orchestrationEventFeed.value.at(-1)?.label).toBe('响应已生成')
|
||||
expect(view.activeAgent.value).toBe('planner')
|
||||
expect(view.orchestrationEventFeed.value.at(-1)?.items.at(-1)?.label).toBe('响应已生成')
|
||||
expect(view.activeAgent.value).toBe('schedule_planner')
|
||||
expect(view.store.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('surfaces schedule fulfillment progress when chat creates a reminder', async () => {
|
||||
mocks.chatStream.mockImplementation(async (_message, _conversationId, _fileIds, _modelName, handlers) => {
|
||||
handlers.onMetadata?.({ conversation_id: 'conv-2', message_id: 'msg-2' })
|
||||
handlers.onProgress?.({
|
||||
stage: 'tool',
|
||||
label: 'Jarvis 正在调用工具',
|
||||
agent: 'executor',
|
||||
tool_name: 'create_reminder',
|
||||
step: '提醒创建成功: [abcd1234] 站会 @ 2026-03-28T09:00:00',
|
||||
steps: [],
|
||||
})
|
||||
handlers.onChunk?.({ content: '已经帮你建好提醒。' })
|
||||
})
|
||||
|
||||
const view = useChatView()
|
||||
view.inputMessage.value = '明天9点提醒我开站会'
|
||||
|
||||
const promise = view.sendMessage()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(view.orchestrationInsight.value.statusTitle).toBe('FULFILLMENT')
|
||||
expect(view.orchestrationInsight.value.jarvisNote).toContain('真的落到系统里')
|
||||
expect(view.orchestrationEventFeed.value.flatMap((group) => group.items.map((item) => item.label))).toContain('提醒创建成功: [abcd1234] 站会 @ 2026-03-28T09:00:00')
|
||||
|
||||
await promise
|
||||
|
||||
expect(view.store.messages.at(-1)?.content).toBe('已经帮你建好提醒。')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,10 +32,18 @@ interface ThinkingState {
|
||||
|
||||
interface OrchestrationEventItem {
|
||||
id: string
|
||||
time: string
|
||||
label: string
|
||||
kind: 'info' | 'tool' | 'success' | 'error'
|
||||
}
|
||||
|
||||
interface OrchestrationEventGroup {
|
||||
id: string
|
||||
startedAt: string
|
||||
status: 'active' | 'success' | 'error'
|
||||
items: OrchestrationEventItem[]
|
||||
}
|
||||
|
||||
interface OrchestrationInsight {
|
||||
statusTitle: string
|
||||
systemSummary: string
|
||||
@@ -50,8 +58,27 @@ interface TelemetryMetricState {
|
||||
|
||||
interface SystemTelemetryState {
|
||||
cpu: TelemetryMetricState
|
||||
gpu: TelemetryMetricState
|
||||
memory: TelemetryMetricState
|
||||
disk: TelemetryMetricState
|
||||
network: {
|
||||
upload: TelemetryMetricState
|
||||
download: TelemetryMetricState
|
||||
}
|
||||
}
|
||||
|
||||
interface SystemMetaState {
|
||||
systemName: string
|
||||
systemVersion: string
|
||||
hostname: string
|
||||
timestamp: string
|
||||
uptimeSeconds: number
|
||||
diskUsedGb: number
|
||||
diskTotalGb: number
|
||||
gpuName: string | null
|
||||
gpuMemoryTotalMb: number | null
|
||||
gpuMemoryUsedMb: number | null
|
||||
gpuUtilPercent: number | null
|
||||
}
|
||||
|
||||
interface SessionTelemetryState {
|
||||
@@ -63,6 +90,10 @@ interface SessionTelemetryState {
|
||||
|
||||
type OrchestrationStatus = 'idle' | 'active' | 'complete' | 'error'
|
||||
|
||||
const ORCHESTRATION_EVENT_STORAGE_KEY = 'jarvis.chat.orchestration.events'
|
||||
const ORCHESTRATION_EVENT_GROUP_LIMIT = 40
|
||||
const ORCHESTRATION_EVENT_ITEM_LIMIT = 24
|
||||
|
||||
export function useChatView() {
|
||||
const store = useConversationStore()
|
||||
const auth = useAuthStore()
|
||||
@@ -89,11 +120,29 @@ export function useChatView() {
|
||||
})
|
||||
const activeAgent = ref<string | null>(null)
|
||||
const visitedAgents = ref<string[]>([])
|
||||
const orchestrationEventFeed = ref<OrchestrationEventItem[]>([])
|
||||
const orchestrationEventFeed = ref<OrchestrationEventGroup[]>([])
|
||||
const systemTelemetry = ref<SystemTelemetryState>({
|
||||
cpu: { current: null, series: [], online: false },
|
||||
gpu: { current: null, series: [], online: false },
|
||||
memory: { current: null, series: [], online: false },
|
||||
disk: { current: null, series: [], online: false },
|
||||
network: {
|
||||
upload: { current: null, series: [], online: false },
|
||||
download: { current: null, series: [], online: false },
|
||||
},
|
||||
})
|
||||
const systemMeta = ref<SystemMetaState>({
|
||||
systemName: '--',
|
||||
systemVersion: '--',
|
||||
hostname: '--',
|
||||
timestamp: '',
|
||||
uptimeSeconds: 0,
|
||||
diskUsedGb: 0,
|
||||
diskTotalGb: 0,
|
||||
gpuName: null,
|
||||
gpuMemoryTotalMb: null,
|
||||
gpuMemoryUsedMb: null,
|
||||
gpuUtilPercent: null,
|
||||
})
|
||||
const sessionTelemetry = ref<SessionTelemetryState>({
|
||||
activitySeries: [],
|
||||
@@ -105,6 +154,68 @@ export function useChatView() {
|
||||
let systemTelemetryTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sessionTelemetryTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function loadPersistedOrchestrationEvents() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ORCHESTRATION_EVENT_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return
|
||||
orchestrationEventFeed.value = parsed
|
||||
.filter((group): group is OrchestrationEventGroup => (
|
||||
group
|
||||
&& typeof group.id === 'string'
|
||||
&& typeof group.startedAt === 'string'
|
||||
&& ['active', 'success', 'error'].includes(group.status)
|
||||
&& Array.isArray(group.items)
|
||||
))
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item): item is OrchestrationEventItem => (
|
||||
item
|
||||
&& typeof item.id === 'string'
|
||||
&& typeof item.time === 'string'
|
||||
&& typeof item.label === 'string'
|
||||
&& ['info', 'tool', 'success', 'error'].includes(item.kind)
|
||||
)).slice(-ORCHESTRATION_EVENT_ITEM_LIMIT),
|
||||
}))
|
||||
.slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)
|
||||
} catch {
|
||||
window.localStorage.removeItem(ORCHESTRATION_EVENT_STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function persistOrchestrationEvents() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(
|
||||
ORCHESTRATION_EVENT_STORAGE_KEY,
|
||||
JSON.stringify(orchestrationEventFeed.value.slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)),
|
||||
)
|
||||
}
|
||||
|
||||
function currentEventTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function startOrchestrationEventGroup() {
|
||||
const nextGroup: OrchestrationEventGroup = {
|
||||
id: `group-${Date.now()}`,
|
||||
startedAt: currentEventTime(),
|
||||
status: 'active',
|
||||
items: [],
|
||||
}
|
||||
orchestrationEventFeed.value = [
|
||||
...orchestrationEventFeed.value,
|
||||
nextGroup,
|
||||
].slice(-ORCHESTRATION_EVENT_GROUP_LIMIT)
|
||||
persistOrchestrationEvents()
|
||||
}
|
||||
|
||||
function resetOrchestrationState() {
|
||||
orchestrationPanelVisible.value = true
|
||||
orchestrationStatus.value = 'idle'
|
||||
@@ -115,7 +226,6 @@ export function useChatView() {
|
||||
}
|
||||
activeAgent.value = null
|
||||
visitedAgents.value = []
|
||||
orchestrationEventFeed.value = []
|
||||
sessionTelemetry.value = {
|
||||
activitySeries: [],
|
||||
eventsCount: 0,
|
||||
@@ -139,7 +249,7 @@ export function useChatView() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateSystemTelemetry(metric: keyof SystemTelemetryState, value: number | null, online: boolean) {
|
||||
function updateSystemTelemetry(metric: 'cpu' | 'gpu' | 'memory' | 'disk', value: number | null, online: boolean) {
|
||||
const current = systemTelemetry.value[metric]
|
||||
systemTelemetry.value = {
|
||||
...systemTelemetry.value,
|
||||
@@ -151,17 +261,51 @@ export function useChatView() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateNetworkTelemetry(direction: 'upload' | 'download', value: number | null, online: boolean) {
|
||||
const current = systemTelemetry.value.network[direction]
|
||||
systemTelemetry.value = {
|
||||
...systemTelemetry.value,
|
||||
network: {
|
||||
...systemTelemetry.value.network,
|
||||
[direction]: {
|
||||
current: value,
|
||||
online,
|
||||
series: value === null ? current.series : appendTelemetryPoint(current.series, value),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
const response = await systemApi.getStatus()
|
||||
systemMeta.value = {
|
||||
systemName: response.data.system_name,
|
||||
systemVersion: response.data.system_version,
|
||||
hostname: response.data.hostname,
|
||||
timestamp: response.data.timestamp,
|
||||
uptimeSeconds: response.data.uptime_seconds,
|
||||
diskUsedGb: response.data.disk_used_gb,
|
||||
diskTotalGb: response.data.disk_total_gb,
|
||||
gpuName: response.data.gpu_name,
|
||||
gpuMemoryTotalMb: response.data.gpu_memory_total_mb,
|
||||
gpuMemoryUsedMb: response.data.gpu_memory_used_mb,
|
||||
gpuUtilPercent: response.data.gpu_util_percent,
|
||||
}
|
||||
updateSystemTelemetry('cpu', response.data.cpu_percent, true)
|
||||
updateSystemTelemetry('gpu', response.data.gpu_util_percent, response.data.gpu_util_percent !== null)
|
||||
updateSystemTelemetry('memory', response.data.memory_percent, true)
|
||||
updateSystemTelemetry('disk', response.data.disk_percent, true)
|
||||
updateNetworkTelemetry('upload', response.data.network_upload_bps, true)
|
||||
updateNetworkTelemetry('download', response.data.network_download_bps, true)
|
||||
} catch (error) {
|
||||
console.error('加载系统状态失败:', error)
|
||||
updateSystemTelemetry('cpu', systemTelemetry.value.cpu.current, false)
|
||||
updateSystemTelemetry('gpu', systemTelemetry.value.gpu.current, false)
|
||||
updateSystemTelemetry('memory', systemTelemetry.value.memory.current, false)
|
||||
updateSystemTelemetry('disk', systemTelemetry.value.disk.current, false)
|
||||
updateNetworkTelemetry('upload', systemTelemetry.value.network.upload.current, false)
|
||||
updateNetworkTelemetry('download', systemTelemetry.value.network.download.current, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,15 +331,27 @@ export function useChatView() {
|
||||
function pushOrchestrationEvent(label: string, kind: OrchestrationEventItem['kind']) {
|
||||
const normalized = label.trim()
|
||||
if (!normalized) return
|
||||
if (orchestrationEventFeed.value.at(-1)?.label === normalized) return
|
||||
orchestrationEventFeed.value = [
|
||||
...orchestrationEventFeed.value,
|
||||
{
|
||||
id: `${Date.now()}-${orchestrationEventFeed.value.length}`,
|
||||
label: normalized,
|
||||
kind,
|
||||
},
|
||||
].slice(-5)
|
||||
if (orchestrationEventFeed.value.length === 0) {
|
||||
startOrchestrationEventGroup()
|
||||
}
|
||||
const currentGroup = orchestrationEventFeed.value.at(-1)
|
||||
if (!currentGroup) return
|
||||
if (currentGroup.items.at(-1)?.label === normalized) return
|
||||
const nextItem: OrchestrationEventItem = {
|
||||
id: `${Date.now()}-${currentGroup.items.length}`,
|
||||
time: currentEventTime(),
|
||||
label: normalized,
|
||||
kind,
|
||||
}
|
||||
orchestrationEventFeed.value = orchestrationEventFeed.value.map((group, index) => (
|
||||
index === orchestrationEventFeed.value.length - 1
|
||||
? {
|
||||
...group,
|
||||
items: [...group.items, nextItem].slice(-ORCHESTRATION_EVENT_ITEM_LIMIT),
|
||||
}
|
||||
: group
|
||||
))
|
||||
persistOrchestrationEvents()
|
||||
}
|
||||
|
||||
function buildOrchestrationInsight(payload: ChatProgressEvent): OrchestrationInsight {
|
||||
@@ -212,7 +368,7 @@ export function useChatView() {
|
||||
if (payload.stage === 'planning') {
|
||||
return {
|
||||
statusTitle: 'ROUTING',
|
||||
systemSummary: payload.agent === 'planner' ? '已路由至 planner,正在拆解任务' : '正在规划执行链路',
|
||||
systemSummary: payload.agent === 'schedule_planner' ? '已路由至 schedule_planner,正在编排日程' : '正在规划执行链路',
|
||||
jarvisNote: payload.steps?.length
|
||||
? '问题有几层关系,按顺序拆开会体面很多。'
|
||||
: '这一步需要一点秩序感。',
|
||||
@@ -220,12 +376,17 @@ export function useChatView() {
|
||||
}
|
||||
|
||||
if (payload.stage === 'tool') {
|
||||
const isScheduleAction = payload.tool_name
|
||||
? ['create_reminder', 'create_goal', 'create_todo', 'create_schedule_task', 'create_task'].includes(payload.tool_name)
|
||||
: false
|
||||
return {
|
||||
statusTitle: 'EXECUTION',
|
||||
statusTitle: isScheduleAction ? 'FULFILLMENT' : 'EXECUTION',
|
||||
systemSummary: payload.tool_name ? `正在调用工具 · ${payload.tool_name}` : '正在执行操作',
|
||||
jarvisNote: payload.tool_name
|
||||
? '工具链已接通。希望它今天愿意配合。'
|
||||
: '执行阶段开始了,接下来看看链路表现。',
|
||||
jarvisNote: isScheduleAction
|
||||
? '这次不是只给建议,记录会真的落到系统里。'
|
||||
: payload.tool_name
|
||||
? '工具链已接通。希望它今天愿意配合。'
|
||||
: '执行阶段开始了,接下来看看链路表现。',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +435,12 @@ export function useChatView() {
|
||||
jarvisNote: '很好,问题已经收束。',
|
||||
}
|
||||
pushOrchestrationEvent(finalLabel, status === 'error' ? 'error' : 'success')
|
||||
orchestrationEventFeed.value = orchestrationEventFeed.value.map((group, index) => (
|
||||
index === orchestrationEventFeed.value.length - 1
|
||||
? { ...group, status: status === 'error' ? 'error' : 'success' }
|
||||
: group
|
||||
))
|
||||
persistOrchestrationEvents()
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
@@ -287,6 +454,7 @@ export function useChatView() {
|
||||
const tempMessageId = `temp-${Date.now()}`
|
||||
const previousConversationId = store.currentConversationId
|
||||
inputMessage.value = ''
|
||||
startOrchestrationEventGroup()
|
||||
|
||||
store.addMessage({
|
||||
id: tempMessageId,
|
||||
@@ -392,9 +560,7 @@ export function useChatView() {
|
||||
try {
|
||||
const response = await settingsApi.get()
|
||||
const chatModelsList = (response.data.llm_config?.chat || []).filter((model) => model.enabled)
|
||||
const vlmModels = (response.data.llm_config?.vlm || []).filter((model) => model.enabled)
|
||||
// 合并 chat 和 vlm 模型
|
||||
chatModels.value = [...chatModelsList, ...vlmModels]
|
||||
chatModels.value = chatModelsList
|
||||
if (!selectedModelName.value || !chatModels.value.some((model) => model.name === selectedModelName.value)) {
|
||||
selectedModelName.value = chatModels.value[0]?.name || ''
|
||||
}
|
||||
@@ -518,6 +684,7 @@ export function useChatView() {
|
||||
}
|
||||
|
||||
void loadSystemStatus()
|
||||
loadPersistedOrchestrationEvents()
|
||||
startSystemTelemetryPolling()
|
||||
startSessionTelemetryDecay()
|
||||
|
||||
@@ -556,6 +723,7 @@ export function useChatView() {
|
||||
activeAgent,
|
||||
visitedAgents,
|
||||
orchestrationEventFeed,
|
||||
systemMeta,
|
||||
systemTelemetry,
|
||||
sessionTelemetry,
|
||||
sendMessage,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user