e0a5f57793
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
591 lines
19 KiB
TypeScript
591 lines
19 KiB
TypeScript
import React, { useMemo, useState, useRef, useEffect, createContext, useContext, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Avatar, Typography, theme } from 'antd';
|
|
import { ChevronDown, User } from 'lucide-react';
|
|
import { ModelIcon } from '@lobehub/icons';
|
|
import { useConversationStore, useProviderStore, useSettingsStore } from '@/stores';
|
|
import { useUserProfileStore } from '@/stores/userProfileStore';
|
|
import { useResolvedAvatarSrc } from '@/hooks/useResolvedAvatarSrc';
|
|
import { stripBoosapiTags } from '@/lib/chatMarkdown';
|
|
import type { Message } from '@/types';
|
|
|
|
// ── Scroll context — provided by ChatView ──
|
|
|
|
type ScrollToFn = (messageId: string) => void;
|
|
interface MinimapContextValue {
|
|
scrollTo: ScrollToFn;
|
|
scrollBoxRef: React.RefObject<HTMLElement | null>;
|
|
/** Set by programmatic scroll — suppresses detection updates */
|
|
scrollLockRef: React.MutableRefObject<number>;
|
|
/** Forced active ID set by click — overrides detection during lock */
|
|
forcedActiveRef: React.MutableRefObject<string | null>;
|
|
}
|
|
const MinimapScrollContext = createContext<MinimapContextValue | null>(null);
|
|
|
|
export function MinimapScrollProvider({
|
|
children,
|
|
scrollTo,
|
|
scrollBoxRef,
|
|
}: {
|
|
children: React.ReactNode;
|
|
scrollTo: ScrollToFn;
|
|
scrollBoxRef: React.RefObject<HTMLElement | null>;
|
|
}) {
|
|
const scrollLockRef = useRef(0);
|
|
const forcedActiveRef = useRef<string | null>(null);
|
|
const wrappedScrollTo = useCallback<ScrollToFn>((messageId) => {
|
|
// Lock detection for 800ms after programmatic scroll
|
|
scrollLockRef.current = Date.now() + 800;
|
|
forcedActiveRef.current = messageId;
|
|
scrollTo(messageId);
|
|
}, [scrollTo]);
|
|
const value = useMemo(() => ({ scrollTo: wrappedScrollTo, scrollBoxRef, scrollLockRef, forcedActiveRef }), [wrappedScrollTo, scrollBoxRef]);
|
|
return <MinimapScrollContext.Provider value={value}>{children}</MinimapScrollContext.Provider>;
|
|
}
|
|
|
|
function useMinimapContext(): MinimapContextValue {
|
|
const ctx = useContext(MinimapScrollContext);
|
|
return ctx ?? { scrollTo: () => {}, scrollBoxRef: { current: null }, scrollLockRef: { current: 0 }, forcedActiveRef: { current: null } };
|
|
}
|
|
|
|
// ── Types ──
|
|
|
|
interface MinimapEntry {
|
|
index: number;
|
|
msg: Message;
|
|
role: 'user' | 'assistant';
|
|
summary: string;
|
|
modelId?: string | null;
|
|
providerId?: string | null;
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function summarize(content: string, maxLen: number, role: 'user' | 'assistant'): string {
|
|
const stripped = stripBoosapiTags(content, { stripThink: role === 'assistant' })
|
|
.replace(/```[\s\S]*?```/g, '[code]')
|
|
.replace(/\n+/g, ' ')
|
|
.trim();
|
|
return stripped.length > maxLen ? stripped.slice(0, maxLen) + '…' : stripped;
|
|
}
|
|
|
|
function useEntries(): MinimapEntry[] {
|
|
const messages = useConversationStore((s) => s.messages);
|
|
const hasOlderMessages = useConversationStore((s) => s.hasOlderMessages);
|
|
const loadingOlder = useConversationStore((s) => s.loadingOlder);
|
|
|
|
// Eagerly load all older messages so the minimap shows the full conversation
|
|
useEffect(() => {
|
|
if (hasOlderMessages && !loadingOlder) {
|
|
useConversationStore.getState().loadOlderMessages();
|
|
}
|
|
}, [hasOlderMessages, loadingOlder, messages]);
|
|
|
|
return useMemo(() => {
|
|
const active = messages.filter((m) => m.is_active !== false);
|
|
const entries: MinimapEntry[] = [];
|
|
// Track assistant dedup: parentKey → index in entries array
|
|
// Keep the LAST assistant per parent (matches ChatView's assistantByParentId behavior)
|
|
const parentToIdx = new Map<string, number>();
|
|
let idx = 0;
|
|
|
|
for (const msg of active) {
|
|
if (msg.role === 'user') {
|
|
entries.push({
|
|
index: idx++,
|
|
msg,
|
|
role: 'user',
|
|
summary: summarize(msg.content, 30, 'user'),
|
|
});
|
|
} else if (msg.role === 'assistant') {
|
|
const parentKey = msg.parent_message_id || msg.id;
|
|
const existing = parentToIdx.get(parentKey);
|
|
const entry: MinimapEntry = {
|
|
index: existing !== undefined ? entries[existing].index : idx++,
|
|
msg,
|
|
role: 'assistant',
|
|
summary: summarize(msg.content, 30, 'assistant'),
|
|
modelId: msg.model_id,
|
|
providerId: msg.provider_id,
|
|
};
|
|
if (existing !== undefined) {
|
|
entries[existing] = entry;
|
|
} else {
|
|
parentToIdx.set(parentKey, entries.length);
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
}
|
|
return entries;
|
|
}, [messages]);
|
|
}
|
|
|
|
/** Find the bubble wrapper element for a data-boosapi-msg marker */
|
|
function findBubbleEl(marker: Element, scrollBox: HTMLElement): Element {
|
|
let el: Element = marker;
|
|
// Walk up until we find an element whose parent is the scroll box or its first child container
|
|
for (;;) {
|
|
const parent: Element | null = el.parentElement;
|
|
if (!parent || parent === scrollBox) return el;
|
|
if (parent.parentElement === scrollBox) return el;
|
|
el = parent;
|
|
}
|
|
}
|
|
|
|
/** Track which message is currently most visible in the viewport */
|
|
function useActiveMessageId(entries: MinimapEntry[]): string | null {
|
|
const { scrollBoxRef, scrollLockRef, forcedActiveRef } = useMinimapContext();
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
// Set default to last entry initially
|
|
useEffect(() => {
|
|
if (entries.length > 0 && !activeId) {
|
|
setActiveId(entries[entries.length - 1].msg.id);
|
|
}
|
|
}, [entries, activeId]);
|
|
|
|
useEffect(() => {
|
|
if (entries.length === 0) return;
|
|
|
|
const updateActive = () => {
|
|
// During programmatic scroll lock, use the forced active ID
|
|
if (Date.now() < scrollLockRef.current) {
|
|
if (forcedActiveRef.current) {
|
|
setActiveId(forcedActiveRef.current);
|
|
}
|
|
return;
|
|
}
|
|
// Clear forced active once lock expires
|
|
forcedActiveRef.current = null;
|
|
const scrollBox = scrollBoxRef.current;
|
|
if (!scrollBox) return;
|
|
const boxRect = scrollBox.getBoundingClientRect();
|
|
|
|
// Collect rects for first and last entries to detect scroll extremes
|
|
const firstMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[0].msg.id}"]`);
|
|
const lastMarker = scrollBox.querySelector(`[data-boosapi-msg="${entries[entries.length - 1].msg.id}"]`);
|
|
|
|
// Edge case: scrolled to top — first entry's top is at or below viewport top
|
|
if (firstMarker) {
|
|
const firstEl = findBubbleEl(firstMarker, scrollBox);
|
|
const firstRect = firstEl.getBoundingClientRect();
|
|
if (firstRect.top >= boxRect.top - 5) {
|
|
setActiveId(entries[0].msg.id);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Edge case: scrolled to bottom — last entry's bottom is at or above viewport bottom
|
|
if (lastMarker) {
|
|
const lastEl = findBubbleEl(lastMarker, scrollBox);
|
|
const lastRect = lastEl.getBoundingClientRect();
|
|
if (lastRect.bottom <= boxRect.bottom + 5) {
|
|
setActiveId(entries[entries.length - 1].msg.id);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Normal: find entry whose bubble is closest to detection line (25% from top)
|
|
const detectY = boxRect.top + boxRect.height * 0.25;
|
|
let best: { id: string; dist: number } | null = null;
|
|
|
|
for (const entry of entries) {
|
|
const marker = scrollBox.querySelector(`[data-boosapi-msg="${entry.msg.id}"]`);
|
|
if (!marker) continue;
|
|
const el = findBubbleEl(marker, scrollBox);
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.bottom < boxRect.top || rect.top > boxRect.bottom) continue;
|
|
const dist = Math.abs(rect.top - detectY);
|
|
if (!best || dist < best.dist) {
|
|
best = { id: entry.msg.id, dist };
|
|
}
|
|
}
|
|
if (best) setActiveId(best.id);
|
|
};
|
|
|
|
// Wait for scroll box to be available, then attach listener
|
|
let scrollBox: HTMLElement | null = null;
|
|
let retryTimer: ReturnType<typeof setTimeout>;
|
|
|
|
const attach = () => {
|
|
scrollBox = scrollBoxRef.current;
|
|
if (!scrollBox) {
|
|
retryTimer = setTimeout(attach, 200);
|
|
return;
|
|
}
|
|
scrollBox.addEventListener('scroll', updateActive, { passive: true });
|
|
updateActive();
|
|
};
|
|
|
|
attach();
|
|
|
|
return () => {
|
|
clearTimeout(retryTimer);
|
|
scrollBox?.removeEventListener('scroll', updateActive);
|
|
};
|
|
}, [scrollBoxRef, entries]);
|
|
|
|
return activeId;
|
|
}
|
|
|
|
function useModelName(modelId?: string | null, providerId?: string | null): string {
|
|
const providers = useProviderStore((s) => s.providers);
|
|
return useMemo(() => {
|
|
if (!modelId) return '';
|
|
for (const p of providers) {
|
|
if (providerId && p.id !== providerId) continue;
|
|
const model = p.models?.find((m) => m.model_id === modelId);
|
|
if (model) return model.name || model.model_id;
|
|
}
|
|
const parts = modelId.split('/');
|
|
return parts[parts.length - 1];
|
|
}, [modelId, providerId, providers]);
|
|
}
|
|
|
|
function ModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) {
|
|
const name = useModelName(modelId, providerId);
|
|
return <>{name}</>;
|
|
}
|
|
|
|
/** Renders the user avatar matching the chat bubble style */
|
|
function UserAvatarIcon({ size }: { size: number }) {
|
|
const { token } = theme.useToken();
|
|
const profile = useUserProfileStore((s) => s.profile);
|
|
const resolvedSrc = useResolvedAvatarSrc(profile.avatarType, profile.avatarValue);
|
|
|
|
if (profile.avatarType === 'emoji' && profile.avatarValue) {
|
|
return (
|
|
<div
|
|
style={{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: '50%',
|
|
backgroundColor: token.colorFillSecondary,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: Math.round(size * 0.55),
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{profile.avatarValue}
|
|
</div>
|
|
);
|
|
}
|
|
if ((profile.avatarType === 'url' || profile.avatarType === 'file') && profile.avatarValue) {
|
|
const src = profile.avatarType === 'file' ? resolvedSrc : profile.avatarValue;
|
|
return <Avatar size={size} src={src} />;
|
|
}
|
|
return <Avatar size={size} icon={<User size={Math.round(size * 0.5)} />} style={{ backgroundColor: token.colorPrimary }} />;
|
|
}
|
|
|
|
// ── Plan C: FAQ Index ──
|
|
|
|
function FaqIndex({ entries }: { entries: MinimapEntry[] }) {
|
|
const { token } = theme.useToken();
|
|
const activeId = useActiveMessageId(entries);
|
|
|
|
if (entries.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
right: 4,
|
|
top: 8,
|
|
bottom: 8,
|
|
width: 260,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'flex-end',
|
|
zIndex: 10,
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{/* Scrollable dots column */}
|
|
<div
|
|
style={{
|
|
width: 28,
|
|
maxHeight: '100%',
|
|
overflowY: 'auto',
|
|
scrollbarWidth: 'none',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 2,
|
|
pointerEvents: 'auto',
|
|
}}
|
|
>
|
|
{entries.map((entry) => (
|
|
<FaqItem key={entry.msg.id} entry={entry} isActive={activeId === entry.msg.id} token={token} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FaqItem({ entry, isActive, token }: {
|
|
entry: MinimapEntry;
|
|
isActive: boolean;
|
|
token: ReturnType<typeof theme.useToken>['token'];
|
|
}) {
|
|
const [hovered, setHovered] = useState(false);
|
|
const { scrollTo } = useMinimapContext();
|
|
const dotRef = useRef<HTMLDivElement>(null);
|
|
const isUser = entry.role === 'user';
|
|
|
|
// Calculate fixed position for hover card based on dot's position
|
|
const getCardStyle = (): React.CSSProperties => {
|
|
if (!dotRef.current) return { display: 'none' };
|
|
const rect = dotRef.current.getBoundingClientRect();
|
|
return {
|
|
position: 'fixed',
|
|
right: window.innerWidth - rect.left + 6,
|
|
top: rect.top + rect.height / 2,
|
|
transform: 'translateY(-50%)',
|
|
background: token.colorBgElevated,
|
|
border: `1px solid ${token.colorBorderSecondary}`,
|
|
borderRadius: token.borderRadius,
|
|
padding: '4px 8px',
|
|
minWidth: 140,
|
|
maxWidth: 220,
|
|
zIndex: 1000,
|
|
cursor: 'pointer',
|
|
boxShadow: token.boxShadowSecondary,
|
|
pointerEvents: 'auto' as const,
|
|
};
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{ position: 'relative' }}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
>
|
|
{/* Dot */}
|
|
<div
|
|
ref={dotRef}
|
|
onClick={() => scrollTo(entry.msg.id)}
|
|
style={{
|
|
width: 16,
|
|
height: 16,
|
|
borderRadius: '50%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: 8,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
color: isActive ? '#fff' : token.colorTextSecondary,
|
|
backgroundColor: isActive ? token.colorPrimary : token.colorFillQuaternary,
|
|
border: `1px solid ${isActive ? token.colorPrimary : token.colorBorderSecondary}`,
|
|
transition: 'all 0.2s',
|
|
margin: '0 auto',
|
|
overflow: 'hidden',
|
|
...(hovered && !isActive ? {
|
|
backgroundColor: token.colorPrimaryBg,
|
|
borderColor: token.colorPrimary,
|
|
color: token.colorPrimary,
|
|
} : {}),
|
|
}}
|
|
>
|
|
{isUser ? (
|
|
<UserAvatarIcon size={14} />
|
|
) : entry.modelId ? (
|
|
<ModelIcon model={entry.modelId} size={12} type="avatar" />
|
|
) : (
|
|
entry.index + 1
|
|
)}
|
|
</div>
|
|
|
|
{/* Hover card — fixed position to avoid clipping */}
|
|
{hovered && createPortal(
|
|
<div
|
|
onClick={() => scrollTo(entry.msg.id)}
|
|
onMouseEnter={() => setHovered(true)}
|
|
onMouseLeave={() => setHovered(false)}
|
|
style={getCardStyle()}
|
|
>
|
|
<div style={{ fontSize: 10, color: isUser ? token.colorTextSecondary : token.colorPrimary }}>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
|
{!isUser && entry.modelId && <ModelIcon model={entry.modelId} size={10} type="avatar" />}
|
|
{isUser ? 'Q' : <ModelName modelId={entry.modelId} providerId={entry.providerId} />}
|
|
</span>
|
|
</div>
|
|
<Typography.Text
|
|
type="secondary"
|
|
ellipsis
|
|
style={{ fontSize: 10, display: 'block', marginTop: 1 }}
|
|
>
|
|
{entry.summary}
|
|
</Typography.Text>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Plan D: Sticky Header ──
|
|
|
|
function StickyHeader({ entries }: { entries: MinimapEntry[] }) {
|
|
const { token } = theme.useToken();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const activeId = useActiveMessageId(entries);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
if (entries.length === 0) return null;
|
|
|
|
const activeIdx = entries.findIndex((e) => e.msg.id === activeId);
|
|
const current = entries[Math.max(0, activeIdx)];
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
onMouseEnter={() => setExpanded(true)}
|
|
onMouseLeave={() => setExpanded(false)}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 10,
|
|
backdropFilter: 'blur(12px)',
|
|
background: `${token.colorBgContainer}e6`,
|
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
padding: '6px 16px',
|
|
fontSize: 13,
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
<span style={{ color: token.colorPrimary, fontWeight: 600, flexShrink: 0 }}>
|
|
{(activeIdx >= 0 ? activeIdx : 0) + 1} / {entries.length}
|
|
</span>
|
|
{current.role === 'assistant' && current.modelId && (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
|
<ModelIcon model={current.modelId} size={14} type="avatar" />
|
|
<StickyModelName modelId={current.modelId} providerId={current.providerId} />
|
|
</span>
|
|
)}
|
|
{current.role === 'user' && (
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
|
<UserAvatarIcon size={14} />
|
|
</span>
|
|
)}
|
|
<Typography.Text
|
|
ellipsis
|
|
style={{ flex: 1, minWidth: 0, fontSize: 12, color: token.colorTextSecondary }}
|
|
>
|
|
{current.summary}
|
|
</Typography.Text>
|
|
<ChevronDown
|
|
size={14}
|
|
style={{
|
|
color: token.colorTextSecondary,
|
|
transition: 'transform 0.2s',
|
|
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div
|
|
style={{
|
|
maxHeight: 300,
|
|
overflowY: 'auto',
|
|
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
|
}}
|
|
>
|
|
{entries.map((entry) => (
|
|
<StickyDropdownItem
|
|
key={entry.msg.id}
|
|
entry={entry}
|
|
isActive={entry.msg.id === activeId}
|
|
token={token}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StickyModelName({ modelId, providerId }: { modelId?: string | null; providerId?: string | null }) {
|
|
const name = useModelName(modelId, providerId);
|
|
const { token } = theme.useToken();
|
|
return <span style={{ fontSize: 12, color: token.colorTextSecondary }}>{name}</span>;
|
|
}
|
|
|
|
function StickyDropdownItem({ entry, isActive, token }: {
|
|
entry: MinimapEntry;
|
|
isActive: boolean;
|
|
token: ReturnType<typeof theme.useToken>['token'];
|
|
}) {
|
|
const { scrollTo } = useMinimapContext();
|
|
const isUser = entry.role === 'user';
|
|
return (
|
|
<div
|
|
onClick={() => scrollTo(entry.msg.id)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
padding: '6px 16px',
|
|
cursor: 'pointer',
|
|
fontSize: 13,
|
|
transition: 'background 0.15s',
|
|
backgroundColor: isActive ? token.colorPrimaryBg : 'transparent',
|
|
borderLeft: isActive ? `2px solid ${token.colorPrimary}` : '2px solid transparent',
|
|
}}
|
|
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = token.colorFillQuaternary; }}
|
|
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? token.colorPrimaryBg : 'transparent'; }}
|
|
>
|
|
<span style={{ width: 24, textAlign: 'right', fontSize: 11, color: token.colorTextQuaternary, flexShrink: 0 }}>
|
|
#{entry.index + 1}
|
|
</span>
|
|
<span style={{ flexShrink: 0 }}>
|
|
{isUser ? (
|
|
<UserAvatarIcon size={16} />
|
|
) : entry.modelId ? (
|
|
<ModelIcon model={entry.modelId} size={16} type="avatar" />
|
|
) : (
|
|
<Avatar size={16} style={{ backgroundColor: token.colorPrimary, fontSize: 10 }}>AI</Avatar>
|
|
)}
|
|
</span>
|
|
<Typography.Text
|
|
ellipsis
|
|
style={{ flex: 1, minWidth: 0, fontSize: 12, color: token.colorTextSecondary }}
|
|
>
|
|
{entry.summary}
|
|
</Typography.Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main Component ──
|
|
|
|
export function ChatMinimap() {
|
|
const enabled = useSettingsStore((s) => s.settings.chat_minimap_enabled);
|
|
const style = useSettingsStore((s) => s.settings.chat_minimap_style ?? 'faq');
|
|
const entries = useEntries();
|
|
|
|
if (!enabled || entries.length < 2) return null;
|
|
|
|
switch (style) {
|
|
case 'faq':
|
|
return <FaqIndex entries={entries} />;
|
|
case 'sticky':
|
|
return <StickyHeader entries={entries} />;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|