mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat: add global chat selected context (#9216)
* feat: add global chat selected context * refactor: store workspace context as references * fix: refresh db context after global mode
This commit is contained in:
@@ -90,7 +90,11 @@
|
||||
let appTooltipCurrentViewNumber = $state(0)
|
||||
|
||||
export function focusInput() {
|
||||
if (aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW) {
|
||||
if (
|
||||
aiChatManager.mode === AIMode.SCRIPT ||
|
||||
aiChatManager.mode === AIMode.FLOW ||
|
||||
aiChatManager.mode === AIMode.GLOBAL
|
||||
) {
|
||||
contextTextareaComponent?.focus()
|
||||
} else {
|
||||
instructionsTextareaComponent?.focus()
|
||||
@@ -390,7 +394,7 @@
|
||||
</script>
|
||||
|
||||
<div use:clickOutside class="relative">
|
||||
{#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW}
|
||||
{#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW || aiChatManager.mode === AIMode.GLOBAL}
|
||||
{#if showContext}
|
||||
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 no-scrollbar">
|
||||
<Popover>
|
||||
|
||||
@@ -573,7 +573,7 @@ class AIChatManager {
|
||||
} else if (this.mode === AIMode.NAVIGATOR) {
|
||||
return prepareNavigatorUserMessage(pendingPrompt)
|
||||
} else if (this.mode === AIMode.GLOBAL) {
|
||||
return prepareGlobalUserMessage(pendingPrompt)
|
||||
return prepareGlobalUserMessage(pendingPrompt, this.contextManager.getSelectedContext())
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
@@ -750,7 +750,7 @@ class AIChatManager {
|
||||
role: 'user',
|
||||
content: this.instructions,
|
||||
contextElements:
|
||||
this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW
|
||||
this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW || this.mode === AIMode.GLOBAL
|
||||
? oldSelectedContext
|
||||
: undefined,
|
||||
snapshot,
|
||||
@@ -790,7 +790,7 @@ class AIChatManager {
|
||||
userMessage = prepareApiUserMessage(oldInstructions)
|
||||
break
|
||||
case AIMode.GLOBAL:
|
||||
userMessage = prepareGlobalUserMessage(oldInstructions)
|
||||
userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext)
|
||||
break
|
||||
case AIMode.APP:
|
||||
userMessage = prepareAppUserMessage(
|
||||
@@ -1035,6 +1035,11 @@ class AIChatManager {
|
||||
!copilotSessionModel?.model.endsWith('/thinking'),
|
||||
untrack(() => this.contextManager.getSelectedContext())
|
||||
)
|
||||
} else if (this.mode === AIMode.GLOBAL) {
|
||||
this.contextManager.updateAvailableContextForGlobal(
|
||||
workspaceStore ?? '',
|
||||
untrack(() => this.contextManager.getSelectedContext())
|
||||
)
|
||||
}
|
||||
|
||||
if (this.scriptEditorOptions) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { FlowModule } from '$lib/gen/types.gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { workspaceRunnablesSearch, MAX_RUNNABLE_CONTENT_LENGTH } from './shared'
|
||||
import { workspaceRunnablesSearch } from './shared'
|
||||
import {
|
||||
ContextIconMap,
|
||||
type ContextElement,
|
||||
@@ -167,54 +167,31 @@
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleWorkspaceItemSelect(path: string) {
|
||||
const workspace = $workspaceStore
|
||||
if (!workspace || !onSelectWorkspaceItem) return
|
||||
function handleWorkspaceItemSelect(item: { path: string; summary?: string }) {
|
||||
if (!onSelectWorkspaceItem) return
|
||||
|
||||
try {
|
||||
if (currentView === 'scripts') {
|
||||
const script = await workspaceRunnablesSearch.getScript(path, workspace)
|
||||
const content = script.content ?? ''
|
||||
const truncatedContent =
|
||||
content.length > MAX_RUNNABLE_CONTENT_LENGTH
|
||||
? content.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
|
||||
: content
|
||||
const element: WorkspaceScriptElement & { deletable: boolean } = {
|
||||
type: 'workspace_script',
|
||||
path: script.path,
|
||||
title: script.path,
|
||||
summary: script.summary,
|
||||
language: script.language,
|
||||
content: truncatedContent,
|
||||
schema: script.schema,
|
||||
deletable: true
|
||||
}
|
||||
onSelectWorkspaceItem(element)
|
||||
} else if (currentView === 'flows') {
|
||||
const flow = await workspaceRunnablesSearch.getFlow(path, workspace)
|
||||
const flowValue = JSON.stringify(flow.value, null, 2)
|
||||
const truncatedValue =
|
||||
flowValue.length > MAX_RUNNABLE_CONTENT_LENGTH
|
||||
? flowValue.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
|
||||
: flowValue
|
||||
const element: WorkspaceFlowElement & { deletable: boolean } = {
|
||||
type: 'workspace_flow',
|
||||
path: flow.path,
|
||||
title: flow.path,
|
||||
summary: flow.summary,
|
||||
description: flow.description || '',
|
||||
value: truncatedValue,
|
||||
schema: flow.schema,
|
||||
deletable: true
|
||||
}
|
||||
onSelectWorkspaceItem(element)
|
||||
if (currentView === 'scripts') {
|
||||
const element: WorkspaceScriptElement & { deletable: boolean } = {
|
||||
type: 'workspace_script',
|
||||
path: item.path,
|
||||
title: item.path,
|
||||
summary: item.summary,
|
||||
deletable: true
|
||||
}
|
||||
currentView = 'categories'
|
||||
workspaceSearchQuery = ''
|
||||
workspaceSearchResults = []
|
||||
} catch (err) {
|
||||
console.error('Error fetching workspace item', err)
|
||||
onSelectWorkspaceItem(element)
|
||||
} else if (currentView === 'flows') {
|
||||
const element: WorkspaceFlowElement & { deletable: boolean } = {
|
||||
type: 'workspace_flow',
|
||||
path: item.path,
|
||||
title: item.path,
|
||||
summary: item.summary,
|
||||
deletable: true
|
||||
}
|
||||
onSelectWorkspaceItem(element)
|
||||
}
|
||||
currentView = 'categories'
|
||||
workspaceSearchQuery = ''
|
||||
workspaceSearchResults = []
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -241,7 +218,7 @@
|
||||
e.stopPropagation()
|
||||
const selectedItem = workspaceSearchResults[itemSelectedIndex]
|
||||
if (selectedItem) {
|
||||
handleWorkspaceItemSelect(selectedItem.path)
|
||||
handleWorkspaceItemSelect(selectedItem)
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
@@ -358,7 +335,7 @@
|
||||
>
|
||||
{#if stringSearch.length > 0}
|
||||
<!-- Search view - show flat list -->
|
||||
{#each filteredAvailableContext as element, i}
|
||||
{#each filteredAvailableContext as element, i (element.type + '-' + element.title)}
|
||||
{@const Icon = ContextIconMap[element.type]}
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
|
||||
@@ -386,7 +363,7 @@
|
||||
{/if}
|
||||
{:else if currentView === 'categories'}
|
||||
<!-- Categories view -->
|
||||
{#each availableCategories as category, i}
|
||||
{#each availableCategories as category, i (category.id)}
|
||||
{@const Icon = category.icon}
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md p-1 pr-0 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
|
||||
@@ -432,7 +409,7 @@
|
||||
No results found
|
||||
</div>
|
||||
{:else}
|
||||
{#each workspaceSearchResults as item, i}
|
||||
{#each workspaceSearchResults as item, i (currentView + '-' + item.path)}
|
||||
{@const isAlreadySelected = selectedContext.some(
|
||||
(c) =>
|
||||
((c.type === 'workspace_script' && currentView === 'scripts') ||
|
||||
@@ -446,7 +423,7 @@
|
||||
: ''} {isAlreadySelected ? 'opacity-50' : ''}"
|
||||
onclick={() => {
|
||||
if (!isAlreadySelected) {
|
||||
handleWorkspaceItemSelect(item.path)
|
||||
handleWorkspaceItemSelect(item)
|
||||
}
|
||||
}}
|
||||
disabled={isAlreadySelected}
|
||||
@@ -478,7 +455,7 @@
|
||||
{#if currentCategoryItems.length === 0}
|
||||
<div class="text-center text-primary text-xs py-2">No items in this category</div>
|
||||
{:else}
|
||||
{#each currentCategoryItems as element, i}
|
||||
{#each currentCategoryItems as element, i (element.type + '-' + element.title)}
|
||||
{@const Icon = ContextIconMap[element.type]}
|
||||
<button
|
||||
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
|
||||
|
||||
@@ -34,6 +34,7 @@ export default class ContextManager {
|
||||
private availableContext: ContextElement[] = $state([])
|
||||
|
||||
private workspace: string | undefined = undefined
|
||||
private dbResourcesWorkspace: string | undefined = undefined
|
||||
private dbResources: ListResourceResponse = []
|
||||
private scriptOptions: ScriptOptions | undefined = undefined
|
||||
|
||||
@@ -42,6 +43,7 @@ export default class ContextManager {
|
||||
workspace: workspace,
|
||||
resourceType: SQLSchemaLanguages.join(',')
|
||||
})
|
||||
this.dbResourcesWorkspace = workspace
|
||||
}
|
||||
|
||||
private getSelectedDBSchema(scriptOptions: ScriptOptions, dbSchemas: DBSchemas) {
|
||||
@@ -66,6 +68,19 @@ export default class ContextManager {
|
||||
)
|
||||
}
|
||||
|
||||
updateAvailableContextForGlobal(workspace: string, currentlySelectedContext: ContextElement[]) {
|
||||
this.availableContext = []
|
||||
if (!workspace || (this.workspace !== undefined && this.workspace !== workspace)) {
|
||||
this.workspace = workspace
|
||||
this.selectedContext = []
|
||||
return
|
||||
}
|
||||
this.workspace = workspace
|
||||
this.selectedContext = currentlySelectedContext.filter(
|
||||
(context) => context.type === 'workspace_script' || context.type === 'workspace_flow'
|
||||
)
|
||||
}
|
||||
|
||||
async updateAvailableContextForFlow(
|
||||
flowOptions: FlowOptions,
|
||||
dbSchemas: DBSchemas,
|
||||
@@ -74,10 +89,10 @@ export default class ContextManager {
|
||||
currentlySelectedContext: ContextElement[]
|
||||
) {
|
||||
try {
|
||||
if (this.workspace !== workspace) {
|
||||
if (this.dbResourcesWorkspace !== workspace) {
|
||||
await this.refreshDbResources(workspace)
|
||||
this.workspace = workspace
|
||||
}
|
||||
this.workspace = workspace
|
||||
|
||||
let newAvailableContext: ContextElement[] = []
|
||||
|
||||
@@ -161,10 +176,10 @@ export default class ContextManager {
|
||||
currentlySelectedContext: ContextElement[]
|
||||
) {
|
||||
try {
|
||||
if (this.workspace !== workspace) {
|
||||
if (this.dbResourcesWorkspace !== workspace) {
|
||||
await this.refreshDbResources(workspace)
|
||||
this.workspace = workspace
|
||||
}
|
||||
this.workspace = workspace
|
||||
let newAvailableContext: ContextElement[] = [
|
||||
{
|
||||
type: 'code',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { listResource } = vi.hoisted(() => ({
|
||||
listResource: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('$lib/gen', () => ({
|
||||
ResourceService: {
|
||||
listResource
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('$lib/scripts', () => ({
|
||||
scriptLangToEditorLang: (language: string) => language
|
||||
}))
|
||||
|
||||
vi.mock('$lib/stores', () => ({
|
||||
SQLSchemaLanguages: ['postgresql']
|
||||
}))
|
||||
|
||||
vi.mock('$lib/editorLangUtils', () => ({
|
||||
langToExt: () => 'ts'
|
||||
}))
|
||||
|
||||
describe('ContextManager', () => {
|
||||
beforeEach(() => {
|
||||
listResource.mockReset()
|
||||
})
|
||||
|
||||
const scriptOptions = {
|
||||
lang: 'bun' as const,
|
||||
code: 'export async function main() {}',
|
||||
error: undefined,
|
||||
args: {},
|
||||
path: 'f/scripts/example',
|
||||
diffMode: false
|
||||
}
|
||||
|
||||
it('loads db resources when switching from global to script in the same workspace', async () => {
|
||||
const { default: ContextManager } = await import('./ContextManager.svelte')
|
||||
const manager = new ContextManager()
|
||||
listResource.mockResolvedValueOnce([{ path: 'f/db/main' }])
|
||||
|
||||
manager.updateAvailableContextForGlobal('workspace-a', [])
|
||||
await manager.updateAvailableContext(scriptOptions, {}, 'workspace-a', true, [])
|
||||
|
||||
expect(listResource).toHaveBeenCalledWith({
|
||||
workspace: 'workspace-a',
|
||||
resourceType: 'postgresql'
|
||||
})
|
||||
expect(manager.getAvailableContext()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'db', title: 'f/db/main' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes db resources after switching workspaces through global mode', async () => {
|
||||
const { default: ContextManager } = await import('./ContextManager.svelte')
|
||||
const manager = new ContextManager()
|
||||
listResource
|
||||
.mockResolvedValueOnce([{ path: 'f/db/workspace-a' }])
|
||||
.mockResolvedValueOnce([{ path: 'f/db/workspace-b' }])
|
||||
|
||||
await manager.updateAvailableContext(scriptOptions, {}, 'workspace-a', true, [])
|
||||
manager.updateAvailableContextForGlobal('workspace-b', [])
|
||||
await manager.updateAvailableContext(scriptOptions, {}, 'workspace-b', true, [])
|
||||
|
||||
expect(listResource).toHaveBeenCalledTimes(2)
|
||||
expect(listResource).toHaveBeenLastCalledWith({
|
||||
workspace: 'workspace-b',
|
||||
resourceType: 'postgresql'
|
||||
})
|
||||
expect(manager.getAvailableContext()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'db', title: 'f/db/workspace-b' })])
|
||||
)
|
||||
expect(manager.getAvailableContext()).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ type: 'db', title: 'f/db/workspace-a' })])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Table2
|
||||
} from 'lucide-svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
import type { ScriptLang, Script, OpenFlow } from '$lib/gen/types.gen'
|
||||
import type { ScriptLang } from '$lib/gen/types.gen'
|
||||
import { type DBSchema } from '$lib/stores'
|
||||
import { type Change } from 'diff'
|
||||
import type { AppDatatableMetadata, BackendRunnable, SelectedContext } from './app/core'
|
||||
@@ -213,20 +213,19 @@ export function flattenDatatablesToAppContextElements(
|
||||
}
|
||||
|
||||
/** Workspace script context element — reference to a script in the workspace */
|
||||
export interface WorkspaceScriptElement
|
||||
extends Pick<Script, 'path' | 'summary' | 'language' | 'content' | 'schema'> {
|
||||
export interface WorkspaceScriptElement {
|
||||
type: 'workspace_script'
|
||||
path: string
|
||||
title: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/** Workspace flow context element — reference to a flow in the workspace */
|
||||
export interface WorkspaceFlowElement extends Pick<OpenFlow, 'summary' | 'schema'> {
|
||||
export interface WorkspaceFlowElement {
|
||||
type: 'workspace_flow'
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
/** Full flow value, JSON-stringified and possibly truncated */
|
||||
value: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export type ContextElement = (
|
||||
|
||||
@@ -51,7 +51,7 @@ vi.mock('$lib/gen', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { globalTools } from './core'
|
||||
import { globalTools, prepareGlobalUserMessage } from './core'
|
||||
import { globalDraftStore } from './draftStore.svelte'
|
||||
import type { Tool, ToolCallbacks } from '../shared'
|
||||
|
||||
@@ -238,3 +238,35 @@ describe('global AI tools', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareGlobalUserMessage', () => {
|
||||
it('includes selected workspace item references without contents', () => {
|
||||
const message = prepareGlobalUserMessage('Update these items', [
|
||||
{
|
||||
type: 'workspace_script',
|
||||
path: 'f/scripts/report',
|
||||
title: 'f/scripts/report',
|
||||
summary: 'Report script'
|
||||
},
|
||||
{
|
||||
type: 'workspace_flow',
|
||||
path: 'f/flows/reporting',
|
||||
title: 'f/flows/reporting',
|
||||
summary: 'Reporting flow'
|
||||
}
|
||||
])
|
||||
|
||||
expect(message.content).toContain('## SELECTED CONTEXT')
|
||||
expect(message.content).toContain('- type: script, path: f/scripts/report')
|
||||
expect(message.content).toContain('- type: flow, path: f/flows/reporting')
|
||||
expect(message.content).toContain('## INSTRUCTIONS:\nUpdate these items')
|
||||
expect(message.content).not.toContain('Report script')
|
||||
expect(message.content).not.toContain('Reporting flow')
|
||||
})
|
||||
|
||||
it('omits selected context section when no workspace item is selected', () => {
|
||||
const message = prepareGlobalUserMessage('Create a draft')
|
||||
|
||||
expect(message.content).toBe('## INSTRUCTIONS:\nCreate a draft')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
type ToolCallbacks,
|
||||
type ToolDisplayAction
|
||||
} from '../shared'
|
||||
import type { ContextElement } from '../context'
|
||||
import {
|
||||
resourceRequestSchema,
|
||||
scheduleRequestSchema,
|
||||
@@ -2480,9 +2481,27 @@ export function prepareGlobalSystemMessage(
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareGlobalUserMessage(instructions: string): ChatCompletionUserMessageParam {
|
||||
export function prepareGlobalUserMessage(
|
||||
instructions: string,
|
||||
selectedContext: ContextElement[] = []
|
||||
): ChatCompletionUserMessageParam {
|
||||
const selectedWorkspaceItems = selectedContext.filter(
|
||||
(context) => context.type === 'workspace_script' || context.type === 'workspace_flow'
|
||||
)
|
||||
let content = ''
|
||||
|
||||
if (selectedWorkspaceItems.length > 0) {
|
||||
content += '## SELECTED CONTEXT\n'
|
||||
for (const context of selectedWorkspaceItems) {
|
||||
content += `- type: ${context.type === 'workspace_script' ? 'script' : 'flow'}, path: ${context.path}\n`
|
||||
}
|
||||
content += '\n'
|
||||
}
|
||||
|
||||
content += `## INSTRUCTIONS:\n${instructions}`
|
||||
|
||||
return {
|
||||
role: 'user',
|
||||
content: instructions
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,35 @@ describe('createToolDef', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContextString', () => {
|
||||
it('serializes selected workspace items as references only', async () => {
|
||||
const { buildContextString } = await import('./shared')
|
||||
|
||||
const context = buildContextString([
|
||||
{
|
||||
type: 'workspace_script',
|
||||
path: 'f/scripts/report',
|
||||
title: 'f/scripts/report',
|
||||
summary: 'Report script'
|
||||
},
|
||||
{
|
||||
type: 'workspace_flow',
|
||||
path: 'f/flows/reporting',
|
||||
title: 'f/flows/reporting',
|
||||
summary: 'Reporting flow'
|
||||
}
|
||||
])
|
||||
|
||||
expect(context).toContain('SELECTED WORKSPACE ITEMS:')
|
||||
expect(context).toContain('- type: script, path: f/scripts/report')
|
||||
expect(context).toContain('- type: flow, path: f/flows/reporting')
|
||||
expect(context).not.toContain('Report script')
|
||||
expect(context).not.toContain('Reporting flow')
|
||||
expect(context).not.toContain('Code:')
|
||||
expect(context).not.toContain('Value:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('processToolCall', () => {
|
||||
it('returns pre-confirmation validation errors without asking for confirmation', async () => {
|
||||
const { createToolDef, processToolCall } = await import('./shared')
|
||||
|
||||
@@ -410,21 +410,15 @@ export function buildContextString(selectedContext: ContextElement[]): string {
|
||||
hasFlowModule = true
|
||||
flowModuleContext += `${context.id}\n`
|
||||
} else if (context.type === 'workspace_script') {
|
||||
workspaceItemsContext += `\nWORKSPACE SCRIPT (${context.path}):\n`
|
||||
workspaceItemsContext += `Summary: ${context.summary}\n`
|
||||
workspaceItemsContext += `Language: ${context.language}\n`
|
||||
if (context.schema) {
|
||||
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
|
||||
if (!workspaceItemsContext) {
|
||||
workspaceItemsContext = 'SELECTED WORKSPACE ITEMS:\n'
|
||||
}
|
||||
workspaceItemsContext += `Code:\n${context.content}\n`
|
||||
workspaceItemsContext += `- type: script, path: ${context.path}\n`
|
||||
} else if (context.type === 'workspace_flow') {
|
||||
workspaceItemsContext += `\nWORKSPACE FLOW (${context.path}):\n`
|
||||
workspaceItemsContext += `Summary: ${context.summary}\n`
|
||||
workspaceItemsContext += `Description: ${context.description}\n`
|
||||
if (context.schema) {
|
||||
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
|
||||
if (!workspaceItemsContext) {
|
||||
workspaceItemsContext = 'SELECTED WORKSPACE ITEMS:\n'
|
||||
}
|
||||
workspaceItemsContext += `Value:\n${context.value}\n`
|
||||
workspaceItemsContext += `- type: flow, path: ${context.path}\n`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user