Spaces:
Running
Running
Delete app.py
Browse files
app.py
DELETED
@@ -1,403 +0,0 @@
|
|
1 |
-
import gradio as gr
|
2 |
-
from huggingface_hub import InferenceClient, HfApi
|
3 |
-
import os
|
4 |
-
import requests
|
5 |
-
from typing import List, Dict, Union
|
6 |
-
import traceback
|
7 |
-
from PIL import Image
|
8 |
-
from io import BytesIO
|
9 |
-
import asyncio
|
10 |
-
from gradio_client import Client
|
11 |
-
import time
|
12 |
-
import threading
|
13 |
-
import json
|
14 |
-
|
15 |
-
HF_TOKEN = os.getenv("HF_TOKEN")
|
16 |
-
hf_client = InferenceClient("CohereForAI/c4ai-command-r-plus-08-2024", token=HF_TOKEN)
|
17 |
-
hf_api = HfApi(token=HF_TOKEN)
|
18 |
-
|
19 |
-
def get_headers():
|
20 |
-
if not HF_TOKEN:
|
21 |
-
raise ValueError("Hugging Face token not found in environment variables")
|
22 |
-
return {"Authorization": f"Bearer {HF_TOKEN}"}
|
23 |
-
|
24 |
-
def get_most_liked_spaces(limit: int = 300) -> Union[List[Dict], str]:
|
25 |
-
url = "https://huggingface.co/api/spaces"
|
26 |
-
params = {
|
27 |
-
"sort": "likes",
|
28 |
-
"direction": -1,
|
29 |
-
"limit": limit,
|
30 |
-
"full": "true"
|
31 |
-
}
|
32 |
-
|
33 |
-
try:
|
34 |
-
response = requests.get(url, params=params, headers=get_headers())
|
35 |
-
response.raise_for_status()
|
36 |
-
return response.json()
|
37 |
-
except requests.RequestException as e:
|
38 |
-
return f"API request error: {str(e)}"
|
39 |
-
except ValueError as e:
|
40 |
-
return f"JSON decoding error: {str(e)}"
|
41 |
-
|
42 |
-
def format_space(space: Dict) -> Dict:
|
43 |
-
space_id = space.get('id', 'Unknown')
|
44 |
-
space_name = space_id.split('/')[-1] if '/' in space_id else space_id
|
45 |
-
|
46 |
-
space_author = space.get('author', 'Unknown')
|
47 |
-
if isinstance(space_author, dict):
|
48 |
-
space_author = space_author.get('user', space_author.get('name', 'Unknown'))
|
49 |
-
|
50 |
-
space_likes = space.get('likes', 'N/A')
|
51 |
-
space_url = f"https://huggingface.co/spaces/{space_id}"
|
52 |
-
|
53 |
-
return {
|
54 |
-
"id": space_id,
|
55 |
-
"name": space_name,
|
56 |
-
"author": space_author,
|
57 |
-
"likes": space_likes,
|
58 |
-
"url": space_url,
|
59 |
-
"description": space.get('description', '')
|
60 |
-
}
|
61 |
-
|
62 |
-
def format_spaces(spaces: Union[List[Dict], str]) -> List[Dict]:
|
63 |
-
if isinstance(spaces, str):
|
64 |
-
return [{"error": spaces}]
|
65 |
-
|
66 |
-
return [format_space(space) for space in spaces if isinstance(space, dict)]
|
67 |
-
|
68 |
-
def summarize_space(space: Dict) -> str:
|
69 |
-
system_message = "당신은 Hugging Face Space의 내용을 요약하는 AI 조수입니다. 주어진 정보를 바탕으로 간결하고 명확한 요약을 제공해주세요."
|
70 |
-
user_message = f"다음 Hugging Face Space를 요약해주세요: {space['name']} by {space['author']}. 좋아요 수: {space['likes']}. URL: {space['url']}"
|
71 |
-
|
72 |
-
messages = [
|
73 |
-
{"role": "system", "content": system_message},
|
74 |
-
{"role": "user", "content": user_message}
|
75 |
-
]
|
76 |
-
|
77 |
-
try:
|
78 |
-
response = hf_client.chat_completion(messages, max_tokens=400, temperature=0.7)
|
79 |
-
return response.choices[0].message.content
|
80 |
-
except Exception as e:
|
81 |
-
return f"요약 생성 중 오류 발생: {str(e)}"
|
82 |
-
|
83 |
-
def get_app_py_content(space_id: str) -> str:
|
84 |
-
app_py_url = f"https://huggingface.co/spaces/{space_id}/raw/main/app.py"
|
85 |
-
try:
|
86 |
-
response = requests.get(app_py_url, headers=get_headers())
|
87 |
-
if response.status_code == 200:
|
88 |
-
return response.text # 전체 내용을 반환
|
89 |
-
else:
|
90 |
-
return f"app.py file not found or inaccessible for space: {space_id}"
|
91 |
-
except requests.RequestException:
|
92 |
-
return f"Error fetching app.py content for space: {space_id}"
|
93 |
-
|
94 |
-
def get_space_structure(space_id: str) -> Dict:
|
95 |
-
try:
|
96 |
-
# space_id에서 owner와 repo_name을 분리
|
97 |
-
owner, repo_name = space_id.split('/')
|
98 |
-
|
99 |
-
# HfApi를 사용하여 파일 목록을 가져옴
|
100 |
-
files = hf_api.list_repo_files(repo_id=space_id, repo_type="space")
|
101 |
-
|
102 |
-
# 파일 목록을 트리 구조로 변환
|
103 |
-
tree = {"type": "directory", "path": "", "tree": []}
|
104 |
-
for file in files:
|
105 |
-
path_parts = file.split('/')
|
106 |
-
current = tree
|
107 |
-
for i, part in enumerate(path_parts):
|
108 |
-
if i == len(path_parts) - 1: # 파일
|
109 |
-
current["tree"].append({"type": "file", "path": part})
|
110 |
-
else: # 디렉토리
|
111 |
-
found = False
|
112 |
-
for item in current["tree"]:
|
113 |
-
if item["type"] == "directory" and item["path"] == part:
|
114 |
-
current = item
|
115 |
-
found = True
|
116 |
-
break
|
117 |
-
if not found:
|
118 |
-
new_dir = {"type": "directory", "path": part, "tree": []}
|
119 |
-
current["tree"].append(new_dir)
|
120 |
-
current = new_dir
|
121 |
-
|
122 |
-
return tree
|
123 |
-
except Exception as e:
|
124 |
-
print(f"Error in get_space_structure: {str(e)}")
|
125 |
-
return {"error": f"API request error: {str(e)}"}
|
126 |
-
|
127 |
-
def format_tree_structure(tree_data: Dict, indent: str = "") -> str:
|
128 |
-
formatted = ""
|
129 |
-
for item in tree_data.get("tree", []):
|
130 |
-
if item["type"] == "file":
|
131 |
-
formatted += f"{indent}├── {item['path']}\n"
|
132 |
-
elif item["type"] == "directory":
|
133 |
-
formatted += f"{indent}├── {item['path']}/\n"
|
134 |
-
formatted += format_tree_structure(item, indent + "│ ")
|
135 |
-
return formatted
|
136 |
-
|
137 |
-
def format_list_structure(tree_data: Dict) -> List[str]:
|
138 |
-
formatted = []
|
139 |
-
for item in tree_data.get("tree", []):
|
140 |
-
if item["type"] == "file":
|
141 |
-
formatted.append(item["path"])
|
142 |
-
elif item["type"] == "directory":
|
143 |
-
formatted.append(f"{item['path']}/")
|
144 |
-
formatted.extend(format_list_structure(item))
|
145 |
-
return formatted
|
146 |
-
|
147 |
-
def on_select(space):
|
148 |
-
try:
|
149 |
-
print(f"Selected space: {space['name']}")
|
150 |
-
summary = summarize_space(space)
|
151 |
-
app_content = get_app_py_content(space['id'])
|
152 |
-
tree_structure = get_space_structure(space['id'])
|
153 |
-
|
154 |
-
info = f"선택된 Space: {space['name']} (ID: {space['id']})\n"
|
155 |
-
info += f"Author: {space['author']}\n"
|
156 |
-
info += f"Likes: {space['likes']}\n"
|
157 |
-
info += f"URL: {space['url']}\n\n"
|
158 |
-
info += f"요약:\n{summary}"
|
159 |
-
|
160 |
-
tree_view = format_tree_structure(tree_structure)
|
161 |
-
list_view = "\n".join(format_list_structure(tree_structure))
|
162 |
-
|
163 |
-
print(f"Returning URL: {space['url']}")
|
164 |
-
return info, app_content, space['url'], tree_view, list_view
|
165 |
-
except Exception as e:
|
166 |
-
print(f"Error in on_select: {str(e)}")
|
167 |
-
print(traceback.format_exc())
|
168 |
-
return f"오류가 발생했습니다: {str(e)}", "", "", "", ""
|
169 |
-
|
170 |
-
def update_screenshot(url, last_url, force_update=False):
|
171 |
-
print(f"Updating screenshot. Current URL: {url}, Last URL: {last_url}, Force update: {force_update}")
|
172 |
-
if url and (url != last_url or force_update):
|
173 |
-
screenshot = take_screenshot(url)
|
174 |
-
print("Screenshot updated")
|
175 |
-
return screenshot, url
|
176 |
-
print("No update needed")
|
177 |
-
return gr.update(), last_url
|
178 |
-
|
179 |
-
def refresh_screenshot(url, last_url):
|
180 |
-
print(f"Refresh button clicked. URL: {url}, Last URL: {last_url}")
|
181 |
-
# 항상 강제로 업데이트
|
182 |
-
return update_screenshot(url, last_url, force_update=True)
|
183 |
-
|
184 |
-
def take_screenshot(url):
|
185 |
-
try:
|
186 |
-
print(f"Taking screenshot of URL: {url}")
|
187 |
-
client = Client("ginipick/selenium-screenshot-gradio")
|
188 |
-
result = client.predict(url=url, api_name="/predict")
|
189 |
-
print(f"Screenshot result: {result}")
|
190 |
-
if isinstance(result, str) and os.path.exists(result):
|
191 |
-
return Image.open(result)
|
192 |
-
else:
|
193 |
-
print(f"Invalid result from API: {result}")
|
194 |
-
return Image.new('RGB', (600, 360), color='lightgray')
|
195 |
-
except Exception as e:
|
196 |
-
print(f"Screenshot error: {str(e)}")
|
197 |
-
return Image.new('RGB', (600, 360), color='lightgray')
|
198 |
-
|
199 |
-
def generate_usage_guide(app_content):
|
200 |
-
system_message = "당신은 Python 코드를 분석하여, 화면 보듯이 이용 방법을 설명하는 AI 조수입니다. app.py 코드를 바탕으로 코드에 대한 언급은 제외하고, 이용자 관점에서 1) 기존 유사 기술 방식괴 비교해 특징, 장점에 대해 친절하고 자세하게 상세한 사용 방법을 제공해주세요."
|
201 |
-
user_message = f"다음 Python 코드를 기반으로 화면 UI/UX적 측면으로 특징과 사용 방법을 설명해주세요:\n\n{app_content}"
|
202 |
-
|
203 |
-
messages = [
|
204 |
-
{"role": "system", "content": system_message},
|
205 |
-
{"role": "user", "content": user_message}
|
206 |
-
]
|
207 |
-
|
208 |
-
try:
|
209 |
-
response = hf_client.chat_completion(messages, max_tokens=4000, temperature=0.7)
|
210 |
-
return response.choices[0].message.content
|
211 |
-
except Exception as e:
|
212 |
-
return f"사용 방법 생성 중 오류 발생: {str(e)}"
|
213 |
-
|
214 |
-
def search_spaces(query: str, spaces: List[Dict]) -> List[Dict]:
|
215 |
-
query = query.lower()
|
216 |
-
return [
|
217 |
-
space for space in spaces
|
218 |
-
if query in space['name'].lower() or
|
219 |
-
query in space['author'].lower() or
|
220 |
-
query in space.get('description', '').lower()
|
221 |
-
]
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
def create_ui():
|
226 |
-
try:
|
227 |
-
spaces_list = get_most_liked_spaces()
|
228 |
-
formatted_spaces = format_spaces(spaces_list)
|
229 |
-
print(f"Total spaces loaded: {len(formatted_spaces)}")
|
230 |
-
|
231 |
-
css = """
|
232 |
-
footer {visibility: hidden;}
|
233 |
-
.minimal-button {min-width: 30px !important; height: 25px !important; line-height: 1 !important; font-size: 12px !important; padding: 2px 5px !important;}
|
234 |
-
.space-row {margin-bottom: 5px !important;}
|
235 |
-
#refresh-button, #manual-button, #open-space-button {
|
236 |
-
width: 100% !important;
|
237 |
-
margin-top: 5px !important;
|
238 |
-
}
|
239 |
-
#info-output, #usage-guide, #tree-view, #list-view {
|
240 |
-
height: 400px !important;
|
241 |
-
overflow-y: auto !important;
|
242 |
-
padding-right: 10px !important;
|
243 |
-
}
|
244 |
-
#app-py-content {
|
245 |
-
height: auto !important;
|
246 |
-
max-height: none !important;
|
247 |
-
overflow-y: visible !important;
|
248 |
-
}
|
249 |
-
.output-group {
|
250 |
-
border: 1px solid #ddd;
|
251 |
-
border-radius: 5px;
|
252 |
-
padding: 10px;
|
253 |
-
margin-bottom: 20px;
|
254 |
-
}
|
255 |
-
.scroll-lock {
|
256 |
-
overflow: auto !important;
|
257 |
-
max-height: 400px !important;
|
258 |
-
}
|
259 |
-
.full-height {
|
260 |
-
height: auto !important;
|
261 |
-
max-height: none !important;
|
262 |
-
}
|
263 |
-
"""
|
264 |
-
|
265 |
-
with gr.Blocks(css=css, theme="Nymbo/Nymbo_Theme") as demo:
|
266 |
-
gr.Markdown("# 300: HuggingFace Most Liked Spaces")
|
267 |
-
|
268 |
-
with gr.Row():
|
269 |
-
with gr.Column(scale=1):
|
270 |
-
search_input = gr.Textbox(label="Search Spaces", placeholder="Enter search query...")
|
271 |
-
space_list = gr.HTML()
|
272 |
-
space_state = gr.State(formatted_spaces)
|
273 |
-
|
274 |
-
def update_space_list(query, spaces):
|
275 |
-
filtered_spaces = search_spaces(query, spaces) if query else spaces
|
276 |
-
html = "<div class='space-list'>"
|
277 |
-
for i, space in enumerate(filtered_spaces):
|
278 |
-
html += f"""
|
279 |
-
<div class='space-row'>
|
280 |
-
<span>{space['name']} by {space['author']} (Likes: {space['likes']})</span>
|
281 |
-
<button class='select-space' onclick='selectSpace({i})'>선택</button>
|
282 |
-
</div>
|
283 |
-
"""
|
284 |
-
html += "</div>"
|
285 |
-
return html, filtered_spaces
|
286 |
-
|
287 |
-
search_input.change(update_space_list, inputs=[search_input, space_state], outputs=[space_list, space_state])
|
288 |
-
|
289 |
-
with gr.Column(scale=2):
|
290 |
-
with gr.Tabs():
|
291 |
-
with gr.TabItem("기본 정보"):
|
292 |
-
with gr.Group(elem_classes="output-group scroll-lock"):
|
293 |
-
info_output = gr.Textbox(label="Space 정보 및 요약", elem_id="info-output", lines=20, max_lines=30)
|
294 |
-
url_state = gr.State("")
|
295 |
-
last_url_state = gr.State("")
|
296 |
-
|
297 |
-
screenshot_output = gr.Image(type="pil", label="Live 화면", height=360, width=600)
|
298 |
-
refresh_button = gr.Button("🔄 서비스 화면", elem_id="refresh-button")
|
299 |
-
manual_button = gr.Button("선택 서비스 특징 및 사용법", elem_id="manual-button")
|
300 |
-
|
301 |
-
with gr.Group(elem_classes="output-group scroll-lock"):
|
302 |
-
usage_guide = gr.Textbox(label="선택 서비스 특징 및 사용법", elem_id="usage-guide", visible=False, lines=20, max_lines=30)
|
303 |
-
|
304 |
-
with gr.Group(elem_classes="output-group full-height"):
|
305 |
-
app_py_content = gr.Code(language="python", label="메인 소스코드", elem_id="app-py-content", lines=None, max_lines=None)
|
306 |
-
|
307 |
-
open_space_button = gr.Button("선택한 Space 열기", elem_id="open-space-button")
|
308 |
-
|
309 |
-
with gr.TabItem("코드 구조 분석"):
|
310 |
-
with gr.Group(elem_classes="output-group scroll-lock"):
|
311 |
-
tree_view = gr.Textbox(label="트리 구조", elem_id="tree-view", lines=30, max_lines=50)
|
312 |
-
with gr.Group(elem_classes="output-group scroll-lock"):
|
313 |
-
list_view = gr.Textbox(label="리스트 구조", elem_id="list-view", lines=30, max_lines=50)
|
314 |
-
|
315 |
-
update_trigger = gr.Button("Update Screenshot", visible=False)
|
316 |
-
|
317 |
-
def on_select_with_link(space_index, spaces):
|
318 |
-
space = spaces[space_index]
|
319 |
-
info, app_content, url, tree, list_structure = on_select(space)
|
320 |
-
info += f"\n\n선택한 Space URL: {url}"
|
321 |
-
return info, app_content, url, tree, list_structure
|
322 |
-
|
323 |
-
def open_space_in_browser(url):
|
324 |
-
if url:
|
325 |
-
import webbrowser
|
326 |
-
webbrowser.open(url)
|
327 |
-
return f"'{url}' 주소가 새 탭에서 열렸습니다."
|
328 |
-
return "선택된 Space가 없습니다."
|
329 |
-
|
330 |
-
open_space_button.click(
|
331 |
-
open_space_in_browser,
|
332 |
-
inputs=[url_state],
|
333 |
-
outputs=[gr.Textbox(label="상태 메시지")]
|
334 |
-
)
|
335 |
-
|
336 |
-
refresh_button.click(
|
337 |
-
refresh_screenshot,
|
338 |
-
inputs=[url_state, last_url_state],
|
339 |
-
outputs=[screenshot_output, last_url_state]
|
340 |
-
)
|
341 |
-
|
342 |
-
def show_usage_guide(app_content):
|
343 |
-
usage_text = generate_usage_guide(app_content)
|
344 |
-
return gr.update(value=usage_text, visible=True)
|
345 |
-
|
346 |
-
manual_button.click(
|
347 |
-
show_usage_guide,
|
348 |
-
inputs=[app_py_content],
|
349 |
-
outputs=[usage_guide]
|
350 |
-
)
|
351 |
-
|
352 |
-
update_trigger.click(
|
353 |
-
update_screenshot,
|
354 |
-
inputs=[url_state, last_url_state],
|
355 |
-
outputs=[screenshot_output, last_url_state]
|
356 |
-
)
|
357 |
-
|
358 |
-
# JavaScript 코드를 추가하기 위해 js 함수를 사용합니다.
|
359 |
-
demo.load(js="""
|
360 |
-
function selectSpace(index) {
|
361 |
-
document.querySelector('#space-index').value = index;
|
362 |
-
document.querySelector('#select-space-trigger').click();
|
363 |
-
}
|
364 |
-
""")
|
365 |
-
|
366 |
-
# Hidden elements for space selection
|
367 |
-
space_index = gr.Textbox(elem_id="space-index", visible=False)
|
368 |
-
select_space_trigger = gr.Button("Hidden Select Trigger", visible=False, elem_id="select-space-trigger")
|
369 |
-
|
370 |
-
select_space_trigger.click(
|
371 |
-
on_select_with_link,
|
372 |
-
inputs=[space_index, space_state],
|
373 |
-
outputs=[info_output, app_py_content, url_state, tree_view, list_view]
|
374 |
-
).then(
|
375 |
-
update_screenshot,
|
376 |
-
inputs=[url_state, last_url_state],
|
377 |
-
outputs=[screenshot_output, last_url_state]
|
378 |
-
)
|
379 |
-
|
380 |
-
demo.queue()
|
381 |
-
|
382 |
-
# Start a background thread to trigger updates
|
383 |
-
def trigger_updates():
|
384 |
-
while True:
|
385 |
-
time.sleep(5)
|
386 |
-
update_trigger.click()
|
387 |
-
|
388 |
-
threading.Thread(target=trigger_updates, daemon=True).start()
|
389 |
-
|
390 |
-
return demo
|
391 |
-
|
392 |
-
except Exception as e:
|
393 |
-
print(f"Error in create_ui: {str(e)}")
|
394 |
-
print(traceback.format_exc())
|
395 |
-
raise
|
396 |
-
|
397 |
-
if __name__ == "__main__":
|
398 |
-
try:
|
399 |
-
demo = create_ui()
|
400 |
-
demo.launch()
|
401 |
-
except Exception as e:
|
402 |
-
print(f"Error in main: {str(e)}")
|
403 |
-
print(traceback.format_exc())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|