feat: render image in markdown
This commit is contained in:
@@ -13,7 +13,7 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
.idPrefix('agt_')
|
.idPrefix('agt_')
|
||||||
.withInputOutput(true, true)
|
.withInputOutput(true, true)
|
||||||
.classification('archon')
|
.classification('archon')
|
||||||
.allowedSourceTypes(['config', 'variable', 'data'])
|
.allowedSourceTypes(['config', 'variable', 'data', 'render'])
|
||||||
.allowedTargetTypes(['render'])
|
.allowedTargetTypes(['render'])
|
||||||
.help(NODE_HELP.agent)
|
.help(NODE_HELP.agent)
|
||||||
.menu('Agent', <Bot className={ICON_CLASS} />)
|
.menu('Agent', <Bot className={ICON_CLASS} />)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '@/components/graph/BaseNode'
|
} from '@/components/graph/BaseNode'
|
||||||
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
|
import { Code2, Database, ScrollText, Sparkles, Variable } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarSeparator,
|
MenubarSeparator,
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
MenubarSubContent,
|
MenubarSubContent,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
} from '@/components/ui/menubar'
|
} from '@/components/ui/menubar'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Kbd } from '@/components/ui/kbd'
|
import { Kbd } from '@/components/ui/kbd'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||||
@@ -78,7 +79,16 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
|
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
|
||||||
[nodes, sourceIds]
|
[nodes, sourceIds]
|
||||||
)
|
)
|
||||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
|
const connectedRenderNodes = useMemo(
|
||||||
|
() => getConnectedNodesByType(nodes, sourceIds, 'render'),
|
||||||
|
[nodes, sourceIds]
|
||||||
|
)
|
||||||
|
const hasDependencies =
|
||||||
|
connectedConfigNodes.length > 0 ||
|
||||||
|
connectedVariableNodes.length > 0 ||
|
||||||
|
connectedFunctionNodes.length > 0 ||
|
||||||
|
connectedDataNodes.length > 0 ||
|
||||||
|
connectedRenderNodes.length > 0
|
||||||
|
|
||||||
const setConfigType = useCallback(
|
const setConfigType = useCallback(
|
||||||
(newTypeId: ConfigTypeId) => {
|
(newTypeId: ConfigTypeId) => {
|
||||||
@@ -133,6 +143,18 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[insertAt]
|
[insertAt]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const insertRenderReference = useCallback(
|
||||||
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
|
const isMarkdownImage =
|
||||||
|
configTypeId === 'markdown' && (sourceNode.data?.outputMode ?? 'image') === 'image'
|
||||||
|
const snippet = isMarkdownImage
|
||||||
|
? ``
|
||||||
|
: `{{ ${sourceNode.id} }}`
|
||||||
|
insertAt(snippet, mode)
|
||||||
|
},
|
||||||
|
[insertAt, configTypeId]
|
||||||
|
)
|
||||||
|
|
||||||
const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
|
const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
|
||||||
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
||||||
const configTitles = useMemo(
|
const configTitles = useMemo(
|
||||||
@@ -296,6 +318,40 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||||
</MenubarItem>
|
</MenubarItem>
|
||||||
))}
|
))}
|
||||||
|
{connectedRenderNodes.map((n: any) => {
|
||||||
|
const renderInputDisabled =
|
||||||
|
configTypeId === 'plantuml' || configTypeId === 'wireframe'
|
||||||
|
const item = (
|
||||||
|
<MenubarItem
|
||||||
|
key={n.id}
|
||||||
|
className="text-xs flex items-center gap-2 group"
|
||||||
|
disabled={renderInputDisabled}
|
||||||
|
onClick={() =>
|
||||||
|
!renderInputDisabled && insertRenderReference(n, 'cursor')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Sparkles className="size-3.5 shrink-0" />
|
||||||
|
{n.id}
|
||||||
|
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Kbd>Insert</Kbd>
|
||||||
|
</MenubarShortcut>
|
||||||
|
</MenubarItem>
|
||||||
|
)
|
||||||
|
return renderInputDisabled ? (
|
||||||
|
<TooltipProvider key={n.id} delayDuration={300}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{item}</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
Rendering output is not available for Diagram and
|
||||||
|
Wireframe configs. Use a Markdown config to embed
|
||||||
|
render output.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
item
|
||||||
|
)
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
|
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
.idPrefix('cfg_')
|
.idPrefix('cfg_')
|
||||||
.withInputOutput(true, true)
|
.withInputOutput(true, true)
|
||||||
.classification('psyche')
|
.classification('psyche')
|
||||||
.allowedSourceTypes(['config', 'variable', 'function', 'data'])
|
.allowedSourceTypes(['config', 'variable', 'function', 'data', 'render'])
|
||||||
.allowedTargetTypes(['config', 'render', 'agent'])
|
.allowedTargetTypes(['config', 'render', 'agent'])
|
||||||
.help(NODE_HELP.config)
|
.help(NODE_HELP.config)
|
||||||
.menu('Config', <ScrollText className={ICON_CLASS} />)
|
.menu('Config', <ScrollText className={ICON_CLASS} />)
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
|||||||
})
|
})
|
||||||
nunjucksContext[src.id] = filteredRows
|
nunjucksContext[src.id] = filteredRows
|
||||||
}
|
}
|
||||||
|
if (src?.type === 'render') {
|
||||||
|
nunjucksContext[src.id] =
|
||||||
|
((src.data as Record<string, unknown>)?.cachedOutputValue as string | undefined) ?? ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const functionIdsToRegister = new Set<string>()
|
const functionIdsToRegister = new Set<string>()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
} from '@/components/ui/menubar'
|
} from '@/components/ui/menubar'
|
||||||
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
|
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
|
||||||
import { InputHandle } from '@/components/graph/NodeHandles'
|
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ButtonGroup } from '@/components/ui/button-group'
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
import {
|
import {
|
||||||
@@ -50,16 +50,14 @@ export type { RenderingNodeData }
|
|||||||
|
|
||||||
type Props = AbstractNodeProps<RenderingNodeData>
|
type Props = AbstractNodeProps<RenderingNodeData>
|
||||||
|
|
||||||
type ViewMode = 'preview' | 'raw'
|
|
||||||
|
|
||||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const flowUIContext = useContext(FlowUIContext)
|
const flowUIContext = useContext(FlowUIContext)
|
||||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||||
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||||
|
|
||||||
const state = useRenderingNodeState(id, data)
|
const state = useRenderingNodeState(id, data)
|
||||||
|
const outputMode = state.outputMode
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('preview')
|
|
||||||
const [viewportFocused, setViewportFocused] = useState(false)
|
const [viewportFocused, setViewportFocused] = useState(false)
|
||||||
|
|
||||||
const dimensions =
|
const dimensions =
|
||||||
@@ -68,7 +66,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
|
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [outputMode])
|
||||||
|
|
||||||
const showEmpty =
|
const showEmpty =
|
||||||
state.incomingIds.length === 0 &&
|
state.incomingIds.length === 0 &&
|
||||||
@@ -151,7 +149,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
resizable
|
resizable
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
handles={<InputHandle id="ain" nodeId={id} />}
|
handles={
|
||||||
|
<>
|
||||||
|
<InputHandle id="ain" nodeId={id} />
|
||||||
|
<OutputHandle id="out" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<BaseNodeHeaderRow
|
<BaseNodeHeaderRow
|
||||||
icon={<Sparkles className="size-4" />}
|
icon={<Sparkles className="size-4" />}
|
||||||
@@ -264,17 +267,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
<>
|
<>
|
||||||
<MenubarCheckboxItem
|
<MenubarCheckboxItem
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
checked={viewMode === 'preview'}
|
checked={outputMode === 'image'}
|
||||||
onCheckedChange={(checked) => checked && setViewMode('preview')}
|
onCheckedChange={(checked) => checked && state.setOutputMode('image')}
|
||||||
>
|
>
|
||||||
Preview
|
Image
|
||||||
</MenubarCheckboxItem>
|
</MenubarCheckboxItem>
|
||||||
<MenubarCheckboxItem
|
<MenubarCheckboxItem
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
checked={viewMode === 'raw'}
|
checked={outputMode === 'string'}
|
||||||
onCheckedChange={(checked) => checked && setViewMode('raw')}
|
onCheckedChange={(checked) => checked && state.setOutputMode('string')}
|
||||||
>
|
>
|
||||||
Raw
|
String
|
||||||
</MenubarCheckboxItem>
|
</MenubarCheckboxItem>
|
||||||
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
|
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
|
||||||
</>
|
</>
|
||||||
@@ -306,7 +309,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
</Empty>
|
</Empty>
|
||||||
) : state.error ? (
|
) : state.error ? (
|
||||||
errorUi
|
errorUi
|
||||||
) : viewMode === 'raw' ? (
|
) : outputMode === 'string' ? (
|
||||||
<RawOutputView
|
<RawOutputView
|
||||||
state={state}
|
state={state}
|
||||||
height={rawEditorHeight}
|
height={rawEditorHeight}
|
||||||
@@ -321,12 +324,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
|
|
||||||
<BaseNodeFooter>
|
<BaseNodeFooter>
|
||||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||||
{viewMode === 'raw'
|
{outputMode === 'string'
|
||||||
? state.rawDisplayContent
|
? state.rawDisplayContent
|
||||||
? `Raw · ${state.rawDisplayContent.length} chars`
|
? `String · ${state.rawDisplayContent.length} chars`
|
||||||
: '—'
|
: '—'
|
||||||
: state.renderedContent
|
: state.renderedContent
|
||||||
? `${state.outputLabel} · ${state.renderedContent.length} chars`
|
? `Image · ${state.renderedContent.length} chars`
|
||||||
: state.error
|
: state.error
|
||||||
? 'Error'
|
? 'Error'
|
||||||
: '—'}
|
: '—'}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ export function getRenderNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
{ viewportWidth: 1200, viewportHeight: 800 }
|
{ viewportWidth: 1200, viewportHeight: 800 }
|
||||||
)
|
)
|
||||||
.idPrefix('rnd_')
|
.idPrefix('rnd_')
|
||||||
.withInputOutput(true, false)
|
.withInputOutput(true, true)
|
||||||
.classification('pneuma')
|
.classification('pneuma')
|
||||||
.allowedSourceTypes(['config', 'agent'])
|
.allowedSourceTypes(['config', 'agent'])
|
||||||
|
.allowedTargetTypes(['config', 'agent'])
|
||||||
.help(NODE_HELP.render)
|
.help(NODE_HELP.render)
|
||||||
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
|
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
|
||||||
.connectionLabelByStatus({ default: 'listening', updating: 'giving life', paused: 'pending', error: 'corrupted' })
|
.connectionLabelByStatus({ default: 'listening', updating: 'giving life', paused: 'pending', error: 'corrupted' })
|
||||||
|
|||||||
@@ -24,15 +24,21 @@ import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph
|
|||||||
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
||||||
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||||
|
|
||||||
|
export type OutputMode = 'image' | 'string'
|
||||||
|
|
||||||
export type RenderingNodeData = {
|
export type RenderingNodeData = {
|
||||||
viewportWidth?: number
|
viewportWidth?: number
|
||||||
viewportHeight?: number
|
viewportHeight?: number
|
||||||
updateMode?: 'auto' | 'manual'
|
updateMode?: 'auto' | 'manual'
|
||||||
runTrigger?: number
|
runTrigger?: number
|
||||||
lastRunSourceSignature?: string
|
lastRunSourceSignature?: string
|
||||||
|
/** Controls which view is shown and what the node emits to downstream (Image = markdown embed, String = resolved text). */
|
||||||
|
outputMode?: OutputMode
|
||||||
cachedRenderedContent?: string
|
cachedRenderedContent?: string
|
||||||
cachedResolvedContent?: string
|
cachedResolvedContent?: string
|
||||||
cachedReasoningContent?: string
|
cachedReasoningContent?: string
|
||||||
|
/** Value exposed to config/agent when they reference this node (e.g. {{ renderId }}). Set on pipeline completion. */
|
||||||
|
cachedOutputValue?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||||
@@ -96,6 +102,10 @@ export type RenderingNodeState = {
|
|||||||
|
|
||||||
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
|
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
|
||||||
sourceNodeType: string | null
|
sourceNodeType: string | null
|
||||||
|
|
||||||
|
/** Output mode (Image vs String); controls view and emitted cachedOutputValue. */
|
||||||
|
outputMode: OutputMode
|
||||||
|
setOutputMode: (mode: OutputMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRenderingNodeState(
|
export function useRenderingNodeState(
|
||||||
@@ -140,6 +150,11 @@ export function useRenderingNodeState(
|
|||||||
)
|
)
|
||||||
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
|
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
|
||||||
const runTrigger = data?.runTrigger ?? 0
|
const runTrigger = data?.runTrigger ?? 0
|
||||||
|
const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode
|
||||||
|
const setOutputMode = useCallback(
|
||||||
|
(mode: OutputMode) => updateData({ outputMode: mode }),
|
||||||
|
[updateData]
|
||||||
|
)
|
||||||
const isAgentSource = srcNode?.type === 'agent'
|
const isAgentSource = srcNode?.type === 'agent'
|
||||||
const agentOutputMarkdown = isAgentSource
|
const agentOutputMarkdown = isAgentSource
|
||||||
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
|
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
|
||||||
@@ -201,6 +216,8 @@ export function useRenderingNodeState(
|
|||||||
|
|
||||||
const updateDataRef = useRef(updateData)
|
const updateDataRef = useRef(updateData)
|
||||||
updateDataRef.current = updateData
|
updateDataRef.current = updateData
|
||||||
|
const outputModeRef = useRef(outputMode)
|
||||||
|
outputModeRef.current = outputMode
|
||||||
const setNodesRef = useRef(setNodes)
|
const setNodesRef = useRef(setNodes)
|
||||||
setNodesRef.current = setNodes
|
setNodesRef.current = setNodes
|
||||||
const aiConnectionRef = useRef(aiConnection)
|
const aiConnectionRef = useRef(aiConnection)
|
||||||
@@ -294,6 +311,7 @@ export function useRenderingNodeState(
|
|||||||
cachedRenderedContent: undefined,
|
cachedRenderedContent: undefined,
|
||||||
cachedResolvedContent: undefined,
|
cachedResolvedContent: undefined,
|
||||||
cachedReasoningContent: undefined,
|
cachedReasoningContent: undefined,
|
||||||
|
cachedOutputValue: undefined,
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
||||||
@@ -324,10 +342,18 @@ export function useRenderingNodeState(
|
|||||||
if (thisRunId !== runIdRef.current) return
|
if (thisRunId !== runIdRef.current) return
|
||||||
setRenderedContent(htmlOrSvg)
|
setRenderedContent(htmlOrSvg)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
const mode = outputModeRef.current
|
||||||
|
const cachedOutputValue =
|
||||||
|
mode === 'string'
|
||||||
|
? resolved
|
||||||
|
: mode === 'image' && htmlOrSvg?.trim() && /<svg[\s>]/i.test(htmlOrSvg.trim())
|
||||||
|
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(htmlOrSvg)}`
|
||||||
|
: ''
|
||||||
updateDataRef.current({
|
updateDataRef.current({
|
||||||
cachedRenderedContent: htmlOrSvg,
|
cachedRenderedContent: htmlOrSvg,
|
||||||
cachedResolvedContent: resolved,
|
cachedResolvedContent: resolved,
|
||||||
cachedReasoningContent: reasoning ?? '',
|
cachedReasoningContent: reasoning ?? '',
|
||||||
|
cachedOutputValue,
|
||||||
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
|
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
|
||||||
})
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -546,5 +572,7 @@ export function useRenderingNodeState(
|
|||||||
emptyStateAction,
|
emptyStateAction,
|
||||||
sourceData,
|
sourceData,
|
||||||
sourceNodeType,
|
sourceNodeType,
|
||||||
|
outputMode,
|
||||||
|
setOutputMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user