File size: 21,280 Bytes
b7ede89
 
 
 
1c12943
d7c9976
 
 
 
1c12943
b7ede89
4f0db0e
79e1e8d
2c15d1f
 
b7ede89
99ef753
79e1e8d
 
99ef753
2c15d1f
 
 
4f0db0e
b7ede89
 
d7c9976
b7ede89
4f0db0e
b7ede89
d7c9976
b7ede89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7c9976
4f0db0e
b7ede89
4f0db0e
b7ede89
 
 
d7c9976
2c15d1f
d7c9976
b7ede89
 
 
 
 
 
 
d7c9976
b7ede89
 
d7c9976
b7ede89
 
d7c9976
4f0db0e
b7ede89
4f0db0e
b7ede89
 
 
 
 
d7c9976
79e1e8d
 
d7c9976
b7ede89
 
 
 
 
 
 
d7c9976
b7ede89
 
d7c9976
b7ede89
 
 
d7c9976
4f0db0e
b7ede89
4f0db0e
0f13aa5
b7ede89
 
6c97837
b7ede89
4f0db0e
b7ede89
c074b9a
b7ede89
 
c074b9a
b7ede89
 
 
 
 
 
 
 
 
 
 
 
 
0f13aa5
b7ede89
 
 
 
 
6c97837
d7c9976
b7ede89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d7c9976
b7ede89
 
 
d7c9976
b7ede89
 
 
 
 
 
 
6c97837
d7c9976
4f0db0e
b7ede89
4f0db0e
b7ede89
 
 
 
 
 
 
 
 
 
6c97837
b7ede89
 
 
 
6c97837
0f13aa5
b7ede89
 
 
6c97837
b7ede89
d7c9976
b7ede89
 
 
 
 
 
 
6c97837
d7c9976
b7ede89
 
 
 
 
 
 
 
d7c9976
b7ede89
 
 
d7c9976
b7ede89
 
 
 
 
 
 
 
6c97837
b7ede89
 
 
 
 
 
 
 
 
 
 
 
 
 
d7c9976
 
b7ede89
6c97837
b7ede89
 
d7c9976
b7ede89
 
 
 
 
 
d7c9976
6c97837
b7ede89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c97837
b7ede89
 
 
 
 
 
 
 
 
 
 
 
6c97837
b7ede89
 
 
 
 
 
 
 
 
 
79e1e8d
 
6c97837
 
b7ede89
 
 
 
6c97837
b7ede89
18aeb7a
6c97837
 
b7ede89
 
d7c9976
6c97837
b7ede89
 
 
 
 
 
6c97837
b7ede89
d7c9976
6c97837
b7ede89
 
d7c9976
 
b7ede89
1cbdaf4
b7ede89
 
 
 
 
 
 
 
 
6c97837
b7ede89
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
# requirements.txt 에 다음 내용을 추가하거나 업데이트해야 합니다:
# gradio>=4.0
# requests

import gradio as gr
import requests
from requests.exceptions import RequestException
import json
import time

# --- 전역 변수 및 세션 설정 ---
SERVER_URL = ""
REQUEST_TIMEOUT = 15
session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
    pool_connections=5, pool_maxsize=10,
    max_retries=requests.adapters.Retry(
        total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["GET", "POST"], connect=3, read=3, redirect=3
    )
)
session.mount('http://', adapter)
session.mount('https://', adapter)

# --- 백엔드 통신 함수 ---

def connect_server(ngrok_url):
    """입력된 Ngrok URL로 서버 연결 시도"""
    global SERVER_URL
    print(f"[DEBUG] 서버 연결 시도: {ngrok_url}")
    try:
        if not ngrok_url:
            return "오류: 서버 URL을 입력해주세요."
        # URL 끝 슬래시 제거
        if ngrok_url.endswith('/'):
            ngrok_url = ngrok_url[:-1]

        # '/health' 또는 '/api/status' 엔드포인트로 상태 확인
        health_url = f"{ngrok_url}/health"
        status_url = f"{ngrok_url}/api/status"
        test_url = ""
        response = None

        try:
            test_url = health_url
            print(f"[DEBUG] {test_url} 확인 중...")
            response = session.get(test_url, timeout=5) # 상태 확인은 짧은 타임아웃
            if response.status_code != 200:
                raise RequestException("Health check failed or returned non-200")
        except RequestException as e_health:
            print(f"[DEBUG] {test_url} 실패 ({e_health}), {status_url} 로 재시도...")
            try:
                test_url = status_url
                response = session.get(test_url, timeout=5) # 상태 확인은 짧은 타임아웃
                if response.status_code != 200:
                     raise RequestException("Status check failed or returned non-200")
            except RequestException as e_status:
                 print(f"[ERROR] 서버 연결 최종 실패: {e_status}")
                 return f"서버 연결 실패: {test_url} 확인 중 오류 발생 ({e_status})"

        # 연결 성공 시
        SERVER_URL = ngrok_url
        print(f"[INFO] 서버 연결 성공: {SERVER_URL}")
        return f"서버 연결 성공! ({test_url} 응답코드: {response.status_code})"

    except Exception as e: # 포괄적인 예외 처리
        print(f"[ERROR] 서버 연결 중 예외 발생: {e}")
        return f"서버 연결 중 예상치 못한 오류 발생: {str(e)}"


def check_status():
    """서버의 /api/status 엔드포인트 상태 확인"""
    global SERVER_URL
    if not SERVER_URL:
        return gr.update(value="오류: 서버에 먼저 연결하세요.")
    print(f"[DEBUG] 서버 상태 확인 요청: {SERVER_URL}/api/status")
    try:
        response = session.get(f"{SERVER_URL}/api/status", timeout=REQUEST_TIMEOUT)
        if response.status_code == 200:
            try:
                status_data = json.dumps(response.json(), indent=2, ensure_ascii=False)
                print(f"[INFO] 상태 확인 성공")
                return gr.update(value=status_data)
            except json.JSONDecodeError:
                 print(f"[ERROR] 상태 응답 JSON 파싱 실패")
                 return gr.update(value="오류: 서버 응답 형식이 잘못되었습니다 (JSON 아님).")
        else:
            print(f"[ERROR] 상태 확인 실패: HTTP {response.status_code}")
            return gr.update(value=f"상태 확인 실패: HTTP {response.status_code}\n{response.text}")
    except RequestException as e:
        print(f"[ERROR] 상태 확인 중 네트워크 오류: {e}")
        return gr.update(value=f"상태 확인 중 오류 발생: {str(e)}")

def echo_test(message):
    """서버의 /api/send (echo 액션) 테스트"""
    global SERVER_URL
    if not SERVER_URL:
        return gr.update(value="오류: 서버에 먼저 연결하세요.")
    if not message:
        return gr.update(value="오류: 에코 테스트 메시지를 입력하세요.")
    print(f"[DEBUG] 에코 테스트 요청: {SERVER_URL}/api/send, 메시지: {message}")
    try:
        payload = {"action": "echo", "data": {"message": message}, "timestamp": int(time.time() * 1000)}
        response = session.post(f"{SERVER_URL}/api/send", json=payload, headers={"Content-Type": "application/json"}, timeout=REQUEST_TIMEOUT)
        if response.status_code == 200:
            try:
                echo_data = json.dumps(response.json(), indent=2, ensure_ascii=False)
                print(f"[INFO] 에코 테스트 성공")
                return gr.update(value=echo_data)
            except json.JSONDecodeError:
                 print(f"[ERROR] 에코 응답 JSON 파싱 실패")
                 return gr.update(value="오류: 서버 응답 형식이 잘못되었습니다 (JSON 아님).")
        else:
            print(f"[ERROR] 에코 테스트 실패: HTTP {response.status_code}")
            return gr.update(value=f"에코 테스트 실패: HTTP {response.status_code}\n{response.text}")
    except RequestException as e:
        print(f"[ERROR] 에코 테스트 중 네트워크 오류: {e}")
        return gr.update(value=f"에코 테스트 중 오류 발생: {str(e)}")


def get_programs():
    """서버의 /api/programs 에서 프로그램 목록 조회"""
    global SERVER_URL
    print(f"[DEBUG] 프로그램 목록 조회 함수 시작")
    program_options = []
    default_return = ("<div style='color: gray;'>프로그램 목록이 없습니다.</div>", gr.update(choices=[]), gr.update(choices=[]))

    # 테스트 모드 (서버 URL 없을 때)
    if not SERVER_URL:
        print(f"[DEBUG] 테스트 모드: 프로그램 목록 생성")
        test_programs = [
            {"id": "test1", "name": "테스트 메모장", "description": "텍스트 편집기", "path": "C:\\Windows\\notepad.exe"},
            {"id": "test2", "name": "테스트 계산기", "description": "기본 계산기", "path": "C:\\Windows\\System32\\calc.exe"}
        ]
        html_output = "<div style='padding: 10px; background-color: #fffaeb; border: 1px solid #ffecb3; border-radius: 5px;'>"
        html_output += "<h4 style='margin-top:0; color: #856404;'>[테스트 모드]</h4>"
        html_output += "<p style='font-size: 0.9em;'>서버에 연결되지 않아 테스트 데이터가 표시됩니다.</p>"
        html_output += "<table style='width: 100%; border-collapse: collapse; margin-top: 10px;'><thead><tr style='background-color: #fff3cd;'>"
        html_output += "<th style='padding: 8px; text-align: left;'>이름</th><th style='padding: 8px; text-align: left;'>설명</th></tr></thead><tbody>"
        for p in test_programs:
            program_options.append((f"{p['name']} ({p['description']})", p['id']))
            html_output += f"<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px;'>{p['name']}</td><td style='padding: 8px;'>{p['description']}</td></tr>"
        html_output += "</tbody></table></div>"
        print(f"[DEBUG] 테스트 옵션: {program_options}")
        return html_output, gr.update(choices=program_options), gr.update(choices=program_options)

    # 실제 API 호출 (서버 URL 있을 때)
    try:
        api_url = f"{SERVER_URL}/api/programs"
        print(f"[DEBUG] API 호출: {api_url}")
        headers = {"User-Agent": "GradioClient/1.0", "Accept": "application/json"}
        response = session.get(api_url, headers=headers, timeout=REQUEST_TIMEOUT)
        print(f"[DEBUG] API 응답 코드: {response.status_code}")

        if response.status_code == 200:
            try:
                result_json = response.json()
                programs = result_json.get("programs", [])
                if not programs: # 프로그램 목록이 비어있을 경우
                     print("[INFO] 서버에서 반환된 프로그램 목록이 비어있습니다.")
                     return "<div style='color: gray; padding: 10px;'>서버에 등록된 프로그램이 없습니다.</div>", gr.update(choices=[]), gr.update(choices=[])

                # 프로그램 목록 테이블 생성
                html_output = "<div style='max-height: 300px; overflow-y: auto; border: 1px solid #eee; padding: 10px; border-radius: 5px;'>"
                html_output += "<table style='width: 100%; border-collapse: collapse;'><thead><tr style='background-color: #f8f9fa;'>"
                html_output += "<th style='padding: 8px; text-align: left;'>이름</th><th style='padding: 8px; text-align: left;'>설명</th><th style='padding: 8px; text-align: left;'>경로</th></tr></thead><tbody>"
                for program in programs:
                    program_id = program.get("id", "")
                    program_name = program.get("name", "N/A")
                    program_desc = program.get("description", "")
                    program_path = program.get("path", "")
                    # ID가 유효한 경우에만 옵션 추가
                    if program_id:
                        program_options.append((f"{program_name} ({program_desc})", program_id))
                        html_output += f"<tr style='border-bottom: 1px solid #eee;'><td style='padding: 8px;'>{program_name}</td><td style='padding: 8px;'>{program_desc}</td><td style='padding: 8px;'>{program_path}</td></tr>"
                    else:
                        print(f"[WARN] ID가 없는 프로그램 데이터 발견: {program}")
                        html_output += f"<tr style='border-bottom: 1px solid #eee; background-color: #fff3cd;'><td style='padding: 8px; color: orange;' colspan='3'>[주의] ID가 없는 프로그램: {program_name}</td></tr>"

                html_output += "</tbody></table></div>"
                html_output += f"<div style='margin-top: 10px; font-size: 0.9em; color: #666;'>총 {len(program_options)}개 프로그램 (ID 유효 기준)</div>"
                print(f"[INFO] 프로그램 목록 조회 성공, 옵션 개수: {len(program_options)}")
                return html_output, gr.update(choices=program_options), gr.update(choices=program_options)

            except json.JSONDecodeError:
                 print(f"[ERROR] 프로그램 목록 응답 JSON 파싱 실패")
                 error_html = "<div style='color: red;'>오류: 서버 응답 형식이 잘못되었습니다 (JSON 아님).</div>"
                 return error_html, gr.update(choices=[]), gr.update(choices=[])
        else:
            print(f"[ERROR] 프로그램 목록 조회 실패: HTTP {response.status_code}")
            error_html = f"<div style='color: red;'>프로그램 목록 조회 실패: HTTP {response.status_code}<br>{response.text}</div>"
            return error_html, gr.update(choices=[]), gr.update(choices=[])
    except RequestException as e:
        print(f"[ERROR] 프로그램 목록 조회 중 네트워크 오류: {e}")
        error_html = f"<div style='color: red;'>프로그램 목록 조회 중 오류 발생: {str(e)}</div>"
        return error_html, gr.update(choices=[]), gr.update(choices=[])
    except Exception as e: # 포괄적 예외 처리
         print(f"[ERROR] 프로그램 목록 조회 중 예상치 못한 오류: {e}")
         error_html = f"<div style='color: red;'>프로그램 목록 조회 중 예상치 못한 오류: {str(e)}</div>"
         return error_html, gr.update(choices=[]), gr.update(choices=[])


def execute_program(program_id):
    """선택된 프로그램 ID를 사용하여 서버에 실행 요청"""
    global SERVER_URL
    # 입력 값 타입 로깅 및 검증
    print(f"[DEBUG] 프로그램 실행 시도: ID='{program_id}', Type={type(program_id)}")
    if not isinstance(program_id, str):
        error_msg = f"오류: 프로그램 ID 형식이 잘못되었습니다 (받은 타입: {type(program_id)}). 프로그램을 다시 선택해주세요."
        print(f"[ERROR] {error_msg}")
        return f"<div style='color: red;' class='error-box'>{error_msg}</div>"
    if not program_id:
        error_msg = "오류: 실행할 프로그램을 선택해주세요."
        print(f"[WARN] {error_msg}")
        return f"<div style='color: orange;' class='warning-box'>{error_msg}</div>"

    # 테스트 ID 처리
    if program_id.startswith("test"):
        print(f"[DEBUG] 테스트 프로그램 '{program_id}' 실행 시뮬레이션")
        return f"<div style='color: green;' class='success-box'>테스트 프로그램 '{program_id}' 실행 완료 (시뮬레이션)</div>"

    if not SERVER_URL:
        error_msg = "오류: 서버에 먼저 연결해야 합니다."
        print(f"[WARN] {error_msg}")
        return f"<div style='color: orange;' class='warning-box'>{error_msg}</div>"

    # 실제 API 호출
    try:
        api_url = f"{SERVER_URL}/api/programs/{program_id}/execute"
        print(f"[DEBUG] 서버 실행 요청: {api_url}")
        headers = {"User-Agent": "GradioClient/1.0", "Accept": "application/json", "Content-Type": "application/json"}
        # POST 요청의 본문에 ID 포함 (서버 API 구현에 따라 필요 없을 수도 있음)
        payload = {"id": program_id}
        response = session.post(api_url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
        print(f"[DEBUG] 실행 API 응답 코드: {response.status_code}")

        if response.status_code == 200:
             try:
                result = response.json()
                msg = result.get('message', '프로그램이 성공적으로 실행되었습니다.')
                print(f"[INFO] 프로그램 실행 성공: {msg}")
                return f"<div style='color: green;' class='success-box'>실행 성공: {msg}</div>"
             except json.JSONDecodeError:
                 print(f"[ERROR] 실행 응답 JSON 파싱 실패")
                 return f"<div style='color: red;' class='error-box'>오류: 실행 응답 형식이 잘못되었습니다 (JSON 아님).</div>"
        else:
            error_text = response.text # 오류 메시지 포함
            print(f"[ERROR] 프로그램 실행 실패: HTTP {response.status_code}, 응답: {error_text}")
            return f"<div style='color: red;' class='error-box'>실행 실패: HTTP {response.status_code}<br><pre>{error_text}</pre></div>"
    except RequestException as e:
        error_msg = f"실행 중 네트워크 오류 발생: {str(e)}"
        print(f"[ERROR] {error_msg}")
        return f"<div style='color: red;' class='error-box'>{error_msg}</div>"
    except Exception as e: # 포괄적 예외 처리
         error_msg = f"실행 중 예상치 못한 오류 발생: {str(e)}"
         print(f"[ERROR] {error_msg}")
         return f"<div style='color: red;' class='error-box'>{error_msg}</div>"


# --- Gradio UI 구성 (최신 버전 스타일) ---
with gr.Blocks(title="LocalPCAgent 제어 인터페이스 v4", theme=gr.themes.Soft()) as demo:
    # 사용자 정의 CSS (선택 사항)
    gr.HTML("""
    <style>
        .error-box { padding: 10px; border: 1px solid #dc3545; border-radius: 5px; background-color: #f8d7da; }
        .success-box { padding: 10px; border: 1px solid #28a745; border-radius: 5px; background-color: #d4edda; }
        .warning-box { padding: 10px; border: 1px solid #ffc107; border-radius: 5px; background-color: #fff3cd; }
        table { font-size: 0.9em; }
        th, td { padding: 6px 8px; border-bottom: 1px solid #eee; }
        th { background-color: #f8f9fa; font-weight: 500; }
        tbody tr:hover { background-color: #f1f1f1; }
    </style>
    """)

    gr.Markdown("# LocalPCAgent 제어 인터페이스")
    gr.Markdown("원격 PC의 상태 확인 및 프로그램 실행을 위한 웹 UI입니다.")

    # 섹션 1: 서버 연결
    with gr.Accordion("1. 서버 연결 설정", open=True): # Accordion 사용
        with gr.Row():
            ngrok_url_input = gr.Textbox(
                label="Ngrok URL 입력",
                placeholder="https://xxxx-xx-xxx-xxx.ngrok-free.app 형식",
                scale=3 # 너비 비율
            )
            connect_btn = gr.Button("서버 연결", variant="secondary", scale=1)
        connection_status = gr.Textbox(label="연결 상태", interactive=False)

    # 섹션 2: 기능 탭
    with gr.Tabs():
        # 탭 1: 기본 기능
        with gr.Tab("기본 기능"):
            gr.Markdown("서버의 기본 상태를 확인하고 통신 테스트를 수행합니다.")
            with gr.Row(equal_height=True): # Row 안의 요소들 높이 맞춤
                with gr.Column(scale=1):
                    gr.Markdown("#### 서버 상태")
                    check_status_btn = gr.Button("상태 확인", variant="secondary")
                    gr.Markdown("#### 에코 테스트")
                    echo_message = gr.Textbox(label="보낼 메시지", value="Hello from Gradio!", lines=1)
                    echo_btn = gr.Button("에코 테스트 전송", variant="secondary")
                with gr.Column(scale=2):
                    status_result = gr.Textbox(label="상태 결과", interactive=False, lines=5, max_lines=10)
                    echo_result = gr.Textbox(label="에코 결과", interactive=False, lines=5, max_lines=10)

        # 탭 2: 프로그램 실행
        with gr.Tab("프로그램 실행"):
            gr.Markdown("서버에 등록된 프로그램을 조회하고 실행합니다.")
            with gr.Column():
                gr.Markdown("#### 단계 1: 프로그램 목록 조회")
                with gr.Row(variant='compact'): # 컴포넌트 간 간격 좁힘
                    get_programs_btn = gr.Button("목록 새로고침", variant="secondary")
                    gr.Markdown(f"<small> (네트워크 상태에 따라 최대 {REQUEST_TIMEOUT}초 소요될 수 있습니다)</small>", visible=True) # 안내 문구
                # HTML 결과 표시 영역
                programs_result = gr.HTML(label="등록된 프로그램 목록")
                # 참고용/숨겨진 드롭다운 (UI에는 보이지 않음)
                program_dropdown_display = gr.Dropdown(label="내부용", visible=False)

            with gr.Column():
                gr.Markdown("#### 단계 2: 프로그램 선택 및 실행")
                # 실제 사용자가 선택할 드롭다운
                program_dropdown_exec = gr.Dropdown(
                    label="실행할 프로그램 선택",
                    choices=[], # 초기값은 비어있음
                    interactive=True,
                    info="위 '목록 새로고침' 버튼을 눌러 목록을 가져온 후 선택하세요." # Dropdown 아래 안내 문구
                )
                execute_program_btn = gr.Button("선택한 프로그램 실행", variant="primary") # 주요 실행 버튼 강조
                # 실행 결과 표시 영역
                execute_result = gr.HTML(label="실행 결과")


    # --- 이벤트 핸들러 연결 ---
    connect_btn.click(fn=connect_server, inputs=ngrok_url_input, outputs=connection_status)
    check_status_btn.click(fn=check_status, inputs=None, outputs=status_result)
    echo_btn.click(fn=echo_test, inputs=echo_message, outputs=echo_result)

    # 프로그램 목록 새로고침 버튼 클릭
    get_programs_btn.click(
        fn=get_programs,
        inputs=None,
        # outputs 순서: HTML 결과, 숨겨진 드롭다운, 실행용 드롭다운
        outputs=[programs_result, program_dropdown_display, program_dropdown_exec]
    )

    # 실행할 프로그램 드롭다운 선택 변경 시 (선택 사항: 결과 영역에 즉시 피드백)
    # program_dropdown_exec.change(
    #     fn=lambda choice: f"<div style='color: blue; padding: 5px;'>'{choice}' 프로그램이 선택되었습니다. 실행 버튼을 클릭하세요.</div>" if choice else "",
    #     inputs=program_dropdown_exec,
    #     outputs=execute_result
    # )

    # 프로그램 실행 버튼 클릭
    execute_program_btn.click(
        fn=execute_program,
        inputs=[program_dropdown_exec], # 선택된 드롭다운 값을 입력으로 사용
        outputs=[execute_result] # 실행 결과를 HTML 영역에 표시
    )

# --- 앱 실행 ---
if __name__ == "__main__":
    print("==============================================")
    print("  LocalPCAgent 웹 인터페이스 (Gradio 4.x) 시작 ")
    print("==============================================")
    print("* 서버 URL을 입력하고 '서버 연결'을 먼저 누르세요.")
    print("* 문제가 발생하면 터미널 로그를 확인하세요.")
    print("* Hugging Face Spaces 배포 시 requirements.txt 에")
    print("  'gradio>=4.0' 와 'requests' 를 포함해야 합니다.")
    print("----------------------------------------------")
    # share=True 옵션은 외부 접속 링크 생성 (로컬 테스트 시 불필요하면 제거)
    demo.launch(debug=True, show_error=True)
    print("==============================================")
    print("           웹 인터페이스 종료됨             ")
    print("==============================================")