misukisu commited on
Commit
32ef780
·
verified ·
1 Parent(s): 5541f92

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +590 -0
app.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import asyncio
5
+ import ipaddress
6
+ import urllib.parse
7
+ from typing import List, Dict, Any, Optional
8
+
9
+ import httpx
10
+ from bs4 import BeautifulSoup
11
+ from pydantic import BaseModel, Field, field_validator
12
+ from openai import AsyncOpenAI
13
+ import gradio as gr
14
+
15
+ # =====================================================================
16
+ # 1. CONFIGURATION & CREDENTIAL MANAGEMENT
17
+ # =====================================================================
18
+
19
+ DEFAULT_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
20
+ DEFAULT_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
21
+ DEFAULT_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")
22
+
23
+ # =====================================================================
24
+ # 2. STRICT VALIDATION & INPUT SANITIZATION LAYER
25
+ # =====================================================================
26
+
27
+ DOMAIN_REGEX = re.compile(
28
+ r"^(?:[a-zA-Z0-9]"
29
+ r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+"
30
+ r"[a-zA-Z]{2,63}$"
31
+ )
32
+
33
+ def validate_target_host(target: str) -> str:
34
+ """Validate whether input is a strict IPv4, IPv6, or FQDN string."""
35
+ target = target.strip()
36
+ try:
37
+ ipaddress.ip_address(target)
38
+ return target
39
+ except ValueError:
40
+ pass
41
+
42
+ if DOMAIN_REGEX.match(target):
43
+ return target
44
+
45
+ raise ValueError(f"Invalid target format: '{target}'. Must be a valid IP address or FQDN.")
46
+
47
+ def validate_http_url(url: str) -> str:
48
+ """Validate HTTP/HTTPS URL scheme and network location."""
49
+ url = url.strip()
50
+ parsed = urllib.parse.urlparse(url)
51
+ if parsed.scheme not in ("http", "https"):
52
+ raise ValueError(f"Invalid URL scheme '{parsed.scheme}'. Only http and https are permitted.")
53
+ if not parsed.netloc:
54
+ raise ValueError("URL must contain a valid domain/host network location.")
55
+ return url
56
+
57
+
58
+ class SubdomainEnumInput(BaseModel):
59
+ domain: str = Field(..., description="Target domain to inspect (e.g., example.com)")
60
+
61
+ @field_validator("domain")
62
+ @classmethod
63
+ def check_domain(cls, v: str) -> str:
64
+ return validate_target_host(v)
65
+
66
+
67
+ class NmapScanInput(BaseModel):
68
+ target: str = Field(..., description="Target hostname or IP address to scan")
69
+
70
+ @field_validator("target")
71
+ @classmethod
72
+ def check_target(cls, v: str) -> str:
73
+ return validate_target_host(v)
74
+
75
+
76
+ class WebScraperInput(BaseModel):
77
+ url: str = Field(..., description="Full HTTP/HTTPS URL to inspect")
78
+
79
+ @field_validator("url")
80
+ @classmethod
81
+ def check_url(cls, v: str) -> str:
82
+ return validate_http_url(v)
83
+
84
+
85
+ class PacketCaptureInput(BaseModel):
86
+ pcap_path: str = Field(..., description="Path to local capture file (.pcap, .pcapng, .cap)")
87
+ display_filter: Optional[str] = Field(default="", description="Wireshark display filter string")
88
+
89
+ @field_validator("pcap_path")
90
+ @classmethod
91
+ def sanitize_path(cls, v: str) -> str:
92
+ clean = os.path.basename(v.strip())
93
+ if not clean.endswith((".pcap", ".pcapng", ".cap")):
94
+ raise ValueError("Target file must end with .pcap, .pcapng, or .cap")
95
+ return clean
96
+
97
+ @field_validator("display_filter")
98
+ @classmethod
99
+ def sanitize_filter(cls, v: Optional[str]) -> str:
100
+ if not v:
101
+ return ""
102
+ if not re.match(r"^[a-zA-Z0-9._\s=!<>&|()\"'-]+$", v):
103
+ raise ValueError("Display filter contains unsupported or disallowed characters.")
104
+ return v
105
+
106
+
107
+ # =====================================================================
108
+ # 3. ASYNCHRONOUS DIAGNOSTIC TOOL WRAPPERS
109
+ # =====================================================================
110
+
111
+ async def run_subdomain_enumerator(domain: str) -> Dict[str, Any]:
112
+ """Query certificate transparency logs (crt.sh) for domain asset mapping."""
113
+ try:
114
+ validated = SubdomainEnumInput(domain=domain)
115
+ target_domain = validated.domain
116
+ crt_url = f"https://crt.sh/?q=%25.{target_domain}&output=json"
117
+
118
+ async with httpx.AsyncClient(timeout=15.0) as client:
119
+ response = await client.get(crt_url)
120
+ if response.status_code != 200:
121
+ return {
122
+ "status": "error",
123
+ "message": f"crt.sh query returned status code {response.status_code}",
124
+ "subdomains": []
125
+ }
126
+
127
+ data = response.json()
128
+ subdomains = set()
129
+ for entry in data:
130
+ name_value = entry.get("name_value", "")
131
+ for sub in name_value.split("\n"):
132
+ sub = sub.strip().lower()
133
+ if "*" not in sub and sub.endswith(target_domain):
134
+ subdomains.add(sub)
135
+
136
+ return {
137
+ "status": "success",
138
+ "target": target_domain,
139
+ "count": len(subdomains),
140
+ "subdomains": sorted(list(subdomains))[:100]
141
+ }
142
+ except Exception as exc:
143
+ return {
144
+ "status": "error",
145
+ "message": f"Subdomain enumeration failed: {str(exc)}",
146
+ "subdomains": []
147
+ }
148
+
149
+
150
+ async def run_nmap_port_scanner(target: str) -> Dict[str, Any]:
151
+ """Execute bounded service discovery on top 100 ports using subprocess execution."""
152
+ try:
153
+ validated = NmapScanInput(target=target)
154
+ target_host = validated.target
155
+
156
+ cmd = ["nmap", "-sV", "-T4", "--top-ports", "100", target_host]
157
+
158
+ process = await asyncio.create_subprocess_exec(
159
+ *cmd,
160
+ stdout=asyncio.subprocess.PIPE,
161
+ stderr=asyncio.subprocess.PIPE
162
+ )
163
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=45.0)
164
+
165
+ return {
166
+ "status": "success" if process.returncode == 0 else "warning",
167
+ "exit_code": process.returncode,
168
+ "stdout": stdout.decode("utf-8", errors="replace"),
169
+ "stderr": stderr.decode("utf-8", errors="replace")
170
+ }
171
+ except asyncio.TimeoutError:
172
+ return {
173
+ "status": "error",
174
+ "message": "Nmap scan execution timed out (45s limit)."
175
+ }
176
+ except Exception as exc:
177
+ return {
178
+ "status": "error",
179
+ "message": f"Nmap execution error: {str(exc)}"
180
+ }
181
+
182
+
183
+ async def run_web_scraper_and_analyzer(url: str) -> Dict[str, Any]:
184
+ """Audit HTTP security response headers, meta tags, forms, and script sources."""
185
+ try:
186
+ validated = WebScraperInput(url=url)
187
+ target_url = validated.url
188
+
189
+ headers = {
190
+ "User-Agent": "Diagnostic-Audit-Agent/1.0 (Compliance; Non-Intrusive)"
191
+ }
192
+
193
+ async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
194
+ resp = await client.get(target_url, headers=headers)
195
+ soup = BeautifulSoup(resp.text, "html.parser")
196
+
197
+ sec_headers = {
198
+ "Strict-Transport-Security": resp.headers.get("strict-transport-security", "MISSING"),
199
+ "Content-Security-Policy": resp.headers.get("content-security-policy", "MISSING"),
200
+ "X-Frame-Options": resp.headers.get("x-frame-options", "MISSING"),
201
+ "X-Content-Type-Options": resp.headers.get("x-content-type-options", "MISSING"),
202
+ "Referrer-Policy": resp.headers.get("referrer-policy", "MISSING"),
203
+ "Server": resp.headers.get("server", "UNDISCLOSED")
204
+ }
205
+
206
+ forms = []
207
+ for f in soup.find_all("form")[:10]:
208
+ forms.append({
209
+ "action": f.get("action", ""),
210
+ "method": f.get("method", "GET").upper(),
211
+ "inputs": [inp.get("name") for inp in f.find_all("input") if inp.get("name")]
212
+ })
213
+
214
+ scripts = [s.get("src") for s in soup.find_all("script") if s.get("src")][:15]
215
+
216
+ return {
217
+ "status": "success",
218
+ "effective_url": str(resp.url),
219
+ "http_status": resp.status_code,
220
+ "security_headers": sec_headers,
221
+ "cookies_detected": list(resp.cookies.keys()),
222
+ "forms_discovered": forms,
223
+ "external_scripts": scripts
224
+ }
225
+ except Exception as exc:
226
+ return {
227
+ "status": "error",
228
+ "message": f"Web analyzer error: {str(exc)}"
229
+ }
230
+
231
+
232
+ async def run_packet_capture_analyzer(pcap_path: str, display_filter: str = "") -> Dict[str, Any]:
233
+ """Parse local packet captures using tshark with display filters."""
234
+ try:
235
+ validated = PacketCaptureInput(pcap_path=pcap_path, display_filter=display_filter)
236
+ target_path = validated.pcap_path
237
+ filt = validated.display_filter
238
+
239
+ if not os.path.exists(target_path):
240
+ return {
241
+ "status": "error",
242
+ "message": f"PCAP file '{target_path}' not found in runtime directory."
243
+ }
244
+
245
+ cmd = ["tshark", "-r", target_path, "-c", "50"]
246
+ if filt:
247
+ cmd.extend(["-Y", filt])
248
+
249
+ process = await asyncio.create_subprocess_exec(
250
+ *cmd,
251
+ stdout=asyncio.subprocess.PIPE,
252
+ stderr=asyncio.subprocess.PIPE
253
+ )
254
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=20.0)
255
+
256
+ return {
257
+ "status": "success" if process.returncode == 0 else "warning",
258
+ "exit_code": process.returncode,
259
+ "packet_lines": stdout.decode("utf-8", errors="replace").splitlines()[:50],
260
+ "stderr": stderr.decode("utf-8", errors="replace")
261
+ }
262
+ except Exception as exc:
263
+ return {
264
+ "status": "error",
265
+ "message": f"Tshark analysis error: {str(exc)}"
266
+ }
267
+
268
+
269
+ # =====================================================================
270
+ # 4. TOOL REGISTRY & SCHEMA DEFINITIONS
271
+ # =====================================================================
272
+
273
+ DIAGNOSTIC_TOOLS = [
274
+ {
275
+ "type": "function",
276
+ "function": {
277
+ "name": "subdomain_enumerator",
278
+ "description": "Performs DNS asset discovery via certificate transparency records.",
279
+ "parameters": {
280
+ "type": "object",
281
+ "properties": {
282
+ "domain": {
283
+ "type": "string",
284
+ "description": "Domain to audit (e.g. 'example.com')."
285
+ }
286
+ },
287
+ "required": ["domain"]
288
+ }
289
+ }
290
+ },
291
+ {
292
+ "type": "function",
293
+ "function": {
294
+ "name": "nmap_port_scanner",
295
+ "description": "Performs service and port detection on the top 100 ports of a target host.",
296
+ "parameters": {
297
+ "type": "object",
298
+ "properties": {
299
+ "target": {
300
+ "type": "string",
301
+ "description": "IPv4, IPv6, or FQDN to scan."
302
+ }
303
+ },
304
+ "required": ["target"]
305
+ }
306
+ }
307
+ },
308
+ {
309
+ "type": "function",
310
+ "function": {
311
+ "name": "web_scraper_and_analyzer",
312
+ "description": "Extracts security response headers, forms, cookies, and linked assets from an HTTP/HTTPS endpoint.",
313
+ "parameters": {
314
+ "type": "object",
315
+ "properties": {
316
+ "url": {
317
+ "type": "string",
318
+ "description": "Full HTTP or HTTPS URL to inspect."
319
+ }
320
+ },
321
+ "required": ["url"]
322
+ }
323
+ }
324
+ },
325
+ {
326
+ "type": "function",
327
+ "function": {
328
+ "name": "packet_capture_analyzer",
329
+ "description": "Reads and filters network packet captures from a PCAP file using tshark.",
330
+ "parameters": {
331
+ "type": "object",
332
+ "properties": {
333
+ "pcap_path": {
334
+ "type": "string",
335
+ "description": "Path to the PCAP file."
336
+ },
337
+ "display_filter": {
338
+ "type": "string",
339
+ "description": "Wireshark display filter (e.g. 'http or dns')."
340
+ }
341
+ },
342
+ "required": ["pcap_path"]
343
+ }
344
+ }
345
+ }
346
+ ]
347
+
348
+ TOOL_HANDLERS = {
349
+ "subdomain_enumerator": run_subdomain_enumerator,
350
+ "nmap_port_scanner": run_nmap_port_scanner,
351
+ "web_scraper_and_analyzer": run_web_scraper_and_analyzer,
352
+ "packet_capture_analyzer": run_packet_capture_analyzer
353
+ }
354
+
355
+
356
+ # =====================================================================
357
+ # 5. RECURSIVE AGENTIC REACTION LOOP
358
+ # =====================================================================
359
+
360
+ SYSTEM_PROMPT = """You are an automated System Administration and Infrastructure Diagnostics Agent.
361
+ You assist system administrators and network engineers in auditing network assets, inspecting security configurations, and mapping infrastructure surfaces.
362
+
363
+ Operational Guidelines:
364
+ 1. Systematically investigate target infrastructure using the provided diagnostic tools.
365
+ 2. If tool outputs indicate further investigation is necessary (e.g., newly discovered subdomains or open services), call subsequent tools autonomously.
366
+ 3. Present final summaries structured with: Target Profile, Technical Diagnostics, Configuration Observations, and Remediation/Hardening Recommendations.
367
+ 4. Maintain a neutral, engineering-focused reporting format."""
368
+
369
+
370
+ async def execute_tool(tool_name: str, arguments_json: str) -> str:
371
+ """Safely parse arguments and route to designated diagnostic wrapper."""
372
+ if tool_name not in TOOL_HANDLERS:
373
+ return json.dumps({"error": f"Tool '{tool_name}' not recognized."})
374
+
375
+ try:
376
+ kwargs = json.loads(arguments_json)
377
+ except json.JSONDecodeError:
378
+ return json.dumps({"error": f"Invalid JSON arguments supplied for '{tool_name}'."})
379
+
380
+ handler = TOOL_HANDLERS[tool_name]
381
+ try:
382
+ result = await handler(**kwargs)
383
+ return json.dumps(result, indent=2)
384
+ except Exception as exc:
385
+ return json.dumps({"error": f"Execution error in '{tool_name}': {str(exc)}"})
386
+
387
+
388
+ async def run_agent_loop(
389
+ user_message: str,
390
+ chat_history: List[Dict[str, str]],
391
+ api_key: str,
392
+ base_url: str,
393
+ model_name: str,
394
+ max_steps: int = 5
395
+ ):
396
+ """
397
+ Executes the asynchronous multi-turn tool calling loop against the OpenAI-compatible API.
398
+ Streams execution progress, reasoning traces, and the final synthesis.
399
+ """
400
+ if not api_key:
401
+ yield chat_history + [{"role": "assistant", "content": "API Key is required to initialize the agent client."}], "Configuration Error: Missing API Key."
402
+ return
403
+
404
+ client = AsyncOpenAI(api_key=api_key, base_url=base_url)
405
+
406
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
407
+ for msg in chat_history:
408
+ messages.append({"role": msg["role"], "content": msg["content"]})
409
+ messages.append({"role": "user", "content": user_message})
410
+
411
+ logs = [f"**[INITIALIZE]** Audit Request: `{user_message}`\n"]
412
+ current_chat = chat_history + [{"role": "user", "content": user_message}]
413
+
414
+ step = 0
415
+ while step < max_steps:
416
+ step += 1
417
+ logs.append(f"\n**[ITERATION {step}/{max_steps}] Querying Model...**")
418
+ yield current_chat, "\n".join(logs)
419
+
420
+ try:
421
+ response = await client.chat.completions.create(
422
+ model=model_name,
423
+ messages=messages,
424
+ tools=DIAGNOSTIC_TOOLS,
425
+ tool_choice="auto",
426
+ temperature=0.2
427
+ )
428
+ except Exception as api_err:
429
+ err_text = f"API Request Failed: {str(api_err)}"
430
+ logs.append(f"❌ `{err_text}`")
431
+ current_chat.append({"role": "assistant", "content": err_text})
432
+ yield current_chat, "\n".join(logs)
433
+ return
434
+
435
+ choice = response.choices[0]
436
+ message = choice.message
437
+ reasoning = getattr(message, "reasoning_content", None)
438
+
439
+ if reasoning:
440
+ logs.append(f"\n🧠 **Analytical Reasoning:**\n```text\n{reasoning}\n```")
441
+ yield current_chat, "\n".join(logs)
442
+
443
+ messages.append(message)
444
+
445
+ if message.tool_calls:
446
+ for tool_call in message.tool_calls:
447
+ fn_name = tool_call.function.name
448
+ fn_args = tool_call.function.arguments
449
+
450
+ logs.append(f"⚙️ **Invoking:** `{fn_name}`\nArguments:\n```json\n{fn_args}\n```")
451
+ yield current_chat, "\n".join(logs)
452
+
453
+ tool_output = await execute_tool(fn_name, fn_args)
454
+
455
+ output_preview = tool_output if len(tool_output) <= 600 else f"{tool_output[:600]}... [truncated]"
456
+ logs.append(f"📊 **Result from `{fn_name}`:**\n```json\n{output_preview}\n```")
457
+ yield current_chat, "\n".join(logs)
458
+
459
+ messages.append({
460
+ "role": "tool",
461
+ "tool_call_id": tool_call.id,
462
+ "name": fn_name,
463
+ "content": tool_output
464
+ })
465
+ else:
466
+ final_response = message.content or "Diagnostic review complete."
467
+ logs.append("\n✅ **Audit Completed.**")
468
+ current_chat.append({"role": "assistant", "content": final_response})
469
+ yield current_chat, "\n".join(logs)
470
+ return
471
+
472
+ fallback_msg = "Diagnostic workflow exceeded maximum iteration depth."
473
+ current_chat.append({"role": "assistant", "content": fallback_msg})
474
+ yield current_chat, "\n".join(logs)
475
+
476
+
477
+ # =====================================================================
478
+ # 6. GRADIO USER INTERFACE
479
+ # =====================================================================
480
+
481
+ CUSTOM_CSS = """
482
+ body, .gradio-container {
483
+ background-color: #0b0f19 !important;
484
+ color: #f1f5f9 !important;
485
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
486
+ }
487
+ .gr-box, .gr-panel, .gr-form {
488
+ background-color: #111827 !important;
489
+ border-color: #1f2937 !important;
490
+ }
491
+ #terminal-output textarea {
492
+ background-color: #030712 !important;
493
+ color: #38bdf8 !important;
494
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
495
+ font-size: 0.82rem !important;
496
+ }
497
+ """
498
+
499
+ def create_ui():
500
+ theme = gr.themes.Soft(
501
+ primary_hue="cyan",
502
+ secondary_hue="slate",
503
+ neutral_hue="slate"
504
+ )
505
+
506
+ with gr.Blocks(theme=theme, css=CUSTOM_CSS, title="Network Diagnostics Agent") as demo:
507
+ gr.Markdown(
508
+ """
509
+ # 🌐 Infrastructure Diagnostics & Asset Audit Platform
510
+ ### Asynchronous ReAct Diagnostic Engine with Containerized Tooling
511
+ """
512
+ )
513
+
514
+ with gr.Accordion("⚙️ Endpoint & API Settings", open=False):
515
+ with gr.Row():
516
+ api_key_input = gr.Textbox(
517
+ label="API Key",
518
+ type="password",
519
+ value=DEFAULT_API_KEY,
520
+ placeholder="Enter API Key"
521
+ )
522
+ base_url_input = gr.Textbox(
523
+ label="Base URL",
524
+ value=DEFAULT_BASE_URL
525
+ )
526
+ model_name_input = gr.Textbox(
527
+ label="Model Identifier",
528
+ value=DEFAULT_MODEL
529
+ )
530
+
531
+ with gr.Row():
532
+ with gr.Column(scale=5):
533
+ chatbot = gr.Chatbot(
534
+ label="Diagnostic Workflow Session",
535
+ height=520,
536
+ type="messages",
537
+ show_copy_button=True
538
+ )
539
+ with gr.Row():
540
+ user_input = gr.Textbox(
541
+ label="Audit Command / Target Specification",
542
+ placeholder="E.g., Audit security headers and map open services for scanme.nmap.org",
543
+ lines=2,
544
+ scale=4
545
+ )
546
+ submit_btn = gr.Button("Run Audit", variant="primary", scale=1)
547
+
548
+ clear_btn = gr.ClearButton([user_input, chatbot], value="Clear Session")
549
+
550
+ with gr.Column(scale=4):
551
+ terminal_logs = gr.Markdown(
552
+ value="*Execution logs, tool output traces, and model reasoning will stream here...*",
553
+ elem_id="terminal-output"
554
+ )
555
+
556
+ async def on_submit(user_msg, chat_hist, key, url, model):
557
+ if not user_msg.strip():
558
+ yield chat_hist, "*No input provided.*"
559
+ return
560
+
561
+ chat_hist = chat_hist or []
562
+ async for updated_chat, updated_logs in run_agent_loop(
563
+ user_message=user_msg,
564
+ chat_history=chat_hist,
565
+ api_key=key,
566
+ base_url=url,
567
+ model_name=model
568
+ ):
569
+ yield updated_chat, updated_logs
570
+
571
+ submit_btn.click(
572
+ fn=on_submit,
573
+ inputs=[user_input, chatbot, api_key_input, base_url_input, model_name_input],
574
+ outputs=[chatbot, terminal_logs]
575
+ ).then(
576
+ fn=lambda: "",
577
+ inputs=None,
578
+ outputs=[user_input]
579
+ )
580
+
581
+ return demo
582
+
583
+
584
+ if __name__ == "__main__":
585
+ app = create_ui()
586
+ app.queue(default_concurrency_limit=5).launch(
587
+ server_name="0.0.0.0",
588
+ server_port=7860,
589
+ show_api=False
590
+ )