Nymbo commited on
Commit
dc27384
·
verified ·
1 Parent(s): 717cd1f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +308 -423
app.py CHANGED
@@ -5,12 +5,8 @@ import json
5
  import base64
6
  from PIL import Image
7
  import io
8
- import requests
9
  from smolagents.mcp_client import MCPClient
10
- from mcp import ToolResult # For type hinting, good practice
11
- from mcp.common.content_block import ValueContentBlock # To access the actual tool return value
12
- import numpy as np # For handling audio array
13
- import soundfile as sf # For converting audio array to WAV
14
 
15
  ACCESS_TOKEN = os.getenv("HF_TOKEN")
16
  print("Access token loaded.")
@@ -60,7 +56,7 @@ def connect_to_mcp_server(server_url, server_name=None):
60
  tools = client.get_tools()
61
 
62
  # Store the connection for later use
63
- name = server_name or f"Server_{len(mcp_connections)}"
64
  mcp_connections[name] = {"client": client, "tools": tools, "url": server_url}
65
 
66
  return name, f"Successfully connected to {name} with {len(tools)} available tools"
@@ -86,115 +82,58 @@ def list_mcp_tools(server_name):
86
  def call_mcp_tool(server_name, tool_name, **kwargs):
87
  """Call a specific tool from an MCP server"""
88
  if server_name not in mcp_connections:
89
- return {"error": f"Server '{server_name}' not connected"} # Return dict for consistency
90
 
91
- client_data = mcp_connections[server_name]
92
- client = client_data["client"]
93
- server_tools = client_data["tools"]
94
 
95
  # Find the requested tool
96
- tool = next((t for t in server_tools if t.name == tool_name), None)
97
  if not tool:
98
- return {"error": f"Tool '{tool_name}' not found on server '{server_name}'"}
99
-
100
  try:
101
  # Call the tool with provided arguments
102
- mcp_tool_result: ToolResult = client.call_tool(tool_name=tool_name, arguments=kwargs)
103
-
104
- actual_result = None
105
- if mcp_tool_result.content:
106
- content_block = mcp_tool_result.content[0]
107
- if isinstance(content_block, ValueContentBlock):
108
- actual_result = content_block.value
109
- elif hasattr(content_block, 'text'): # e.g., TextContentBlock
110
- actual_result = content_block.text
111
- else:
112
- actual_result = str(content_block) # Fallback
113
- else: # No content
114
- return {"warning": "Tool returned no content."}
115
-
116
-
117
- # Special handling for audio result (e.g., from Kokoro TTS)
118
- # This checks if the result is a tuple (sample_rate, audio_data_list)
119
- # Gradio MCP server serializes numpy arrays to lists.
120
- if (server_name == "kokoroTTS" and tool_name == "text_to_audio" and
121
- isinstance(actual_result, tuple) and len(actual_result) == 2 and
122
- isinstance(actual_result[0], int) and
123
- (isinstance(actual_result[1], list) or isinstance(actual_result[1], np.ndarray))):
124
-
125
- print(f"Received audio data from {server_name}.{tool_name}")
126
- sample_rate, audio_data_list = actual_result
127
-
128
- # Convert list to numpy array if necessary
129
- audio_data = np.array(audio_data_list)
130
-
131
- # Ensure correct dtype for soundfile (float32 is common, or int16)
132
- # Kokoro returns float, likely in [-1, 1] range.
133
- if audio_data.dtype != np.float32 and audio_data.dtype != np.int16:
134
- # Attempt to normalize if it looks like it's not in [-1, 1] for float
135
- if np.issubdtype(audio_data.dtype, np.floating) and (np.min(audio_data) < -1.1 or np.max(audio_data) > 1.1):
136
- print(f"Warning: Audio data for {server_name}.{tool_name} might not be normalized. Min: {np.min(audio_data)}, Max: {np.max(audio_data)}")
137
- audio_data = audio_data.astype(np.float32)
138
-
139
- wav_io = io.BytesIO()
140
- sf.write(wav_io, audio_data, sample_rate, format='WAV')
141
- wav_io.seek(0)
142
-
143
- wav_b64 = base64.b64encode(wav_io.read()).decode('utf-8')
144
-
145
- return {
146
- "type": "audio_b64",
147
- "data": wav_b64,
148
- "message": f"Audio generated by {server_name}.{tool_name}"
149
- }
150
 
151
- # Handle other types of results
152
- if isinstance(actual_result, dict):
153
- return actual_result
154
- elif isinstance(actual_result, str):
155
- try: # If string is JSON, parse to dict
156
- return json.loads(actual_result)
157
- except json.JSONDecodeError:
158
- return {"text": actual_result} # Wrap raw string
159
- else:
160
- return {"value": str(actual_result)} # Fallback for other primitive types
161
-
162
  except Exception as e:
163
  print(f"Error calling MCP tool: {e}")
164
- import traceback
165
- traceback.print_exc()
166
- return {"error": f"Error calling MCP tool: {str(e)}"}
167
 
168
- def analyze_message_for_tool_call(message, active_mcp_servers, client, model_to_use, system_message):
169
  """Analyze a message to determine if an MCP tool should be called"""
 
170
  if not message or not message.strip():
171
  return None, None
172
 
 
173
  tool_info = []
174
- for server_name in active_mcp_servers:
175
- if server_name in mcp_connections:
176
- server_tools_raw = list_mcp_tools(server_name) # This returns a string
177
- if server_tools_raw != "Server not connected" and server_tools_raw != "No tools available for this server":
178
- # Parse the string from list_mcp_tools
179
- for line in server_tools_raw.split("\n"):
180
- if line.startswith("- "):
181
- parts = line[2:].split(":", 1)
182
- if len(parts) == 2:
183
- tool_info.append({
184
- "server_name": server_name,
185
- "tool_name": parts[0].strip(),
186
- "description": parts[1].strip()
187
- })
188
 
189
  if not tool_info:
190
  return None, None
191
 
 
192
  tools_desc = []
193
  for info in tool_info:
194
  tools_desc.append(f"{info['server_name']}.{info['tool_name']}: {info['description']}")
195
 
196
  tools_string = "\n".join(tools_desc)
197
 
 
198
  analysis_system_prompt = f"""You are an assistant that helps determine if a user message requires using an external tool.
199
  Available tools:
200
  {tools_string}
@@ -205,48 +144,63 @@ Your job is to:
205
  3. If yes, respond ONLY with a JSON object with "server_name", "tool_name", and "parameters".
206
  4. If no, respond ONLY with the exact string "NO_TOOL_NEEDED".
207
 
208
- Example 1 (User wants TTS):
209
  User: "Please turn this text into speech: Hello world"
210
- Response: {{"server_name": "kokoroTTS", "tool_name": "text_to_audio", "parameters": {{"text": "Hello world", "speed": 1.0}}}}
211
 
212
- Example 2 (User wants TTS with different server name):
213
- User: "Use mySpeechTool to say 'good morning'"
214
- Response: {{"server_name": "mySpeechTool", "tool_name": "text_to_audio", "parameters": {{"text": "good morning"}}}}
215
 
216
- Example 3 (User does not want a tool):
217
  User: "What is the capital of France?"
218
  Response: NO_TOOL_NEEDED"""
219
 
220
  try:
221
- response = client.chat_completion(
 
222
  model=model_to_use,
223
  messages=[
224
  {"role": "system", "content": analysis_system_prompt},
225
  {"role": "user", "content": message}
226
  ],
227
- temperature=0.1,
228
  max_tokens=300
229
  )
230
 
231
  analysis = response.choices[0].message.content.strip()
232
- print(f"Tool analysis LLM response: '{analysis}'")
233
 
234
  if analysis == "NO_TOOL_NEEDED":
235
  return None, None
236
 
 
237
  try:
238
  tool_call = json.loads(analysis)
239
- if isinstance(tool_call, dict) and "server_name" in tool_call and "tool_name" in tool_call:
240
- return tool_call.get("server_name"), {
241
- "tool_name": tool_call.get("tool_name"),
242
- "parameters": tool_call.get("parameters", {})
243
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  else:
245
- print(f"LLM response for tool call was not a valid JSON with required keys: {analysis}")
246
  return None, None
247
- except json.JSONDecodeError:
248
- print(f"Failed to parse tool call JSON from LLM: {analysis}")
249
- return None, None
250
 
251
  except Exception as e:
252
  print(f"Error analyzing message for tool calls: {str(e)}")
@@ -273,7 +227,7 @@ def respond(
273
  ):
274
  print(f"Received message: {message}")
275
  print(f"Received {len(image_files) if image_files else 0} images")
276
- # print(f"History: {history}") # Can be verbose
277
  print(f"System message: {system_message}")
278
  print(f"Max tokens: {max_tokens}, Temperature: {temperature}, Top-P: {top_p}")
279
  print(f"Frequency Penalty: {frequency_penalty}, Seed: {seed}")
@@ -293,7 +247,7 @@ def respond(
293
  else:
294
  print("USING DEFAULT API KEY: Environment variable HF_TOKEN is being used for authentication")
295
 
296
- client = InferenceClient(token=token_to_use, provider=provider)
297
  print(f"Hugging Face Inference Client initialized with {provider} provider.")
298
 
299
  if seed == -1:
@@ -310,134 +264,142 @@ def respond(
310
  return
311
 
312
  _, server_name, tool_name = command_parts[:3]
313
- args_json = "{}" if len(command_parts) < 4 else command_parts[3]
314
 
315
  try:
316
- args_dict = json.loads(args_json)
317
  result = call_mcp_tool(server_name, tool_name, **args_dict)
318
-
319
- if isinstance(result, dict) and result.get("type") == "audio_b64":
320
- yield f"<audio controls src=\"data:audio/wav;base64,{result.get('data')}\"></audio>"
321
- elif isinstance(result, dict) and "error" in result:
322
- yield f"Error: {result.get('error')}"
323
  elif isinstance(result, dict):
324
  yield json.dumps(result, indent=2)
325
  else:
326
- yield str(result)
327
- return
328
  except json.JSONDecodeError:
329
- yield f"Invalid JSON arguments: {args_json}"
330
  return
331
  except Exception as e:
332
  yield f"Error executing MCP command: {str(e)}"
333
  return
334
- elif mcp_interaction_mode == "Natural Language" and active_mcp_servers and active_mcp_servers:
335
- print("Attempting natural language tool call detection...")
336
  server_name, tool_info = analyze_message_for_tool_call(
337
- message, active_mcp_servers, client, model_to_use, system_message
 
 
 
 
338
  )
339
 
340
  if server_name and tool_info and tool_info.get("tool_name"):
341
  try:
342
- print(f"Calling tool via natural language: {server_name}.{tool_info['tool_name']} with parameters: {tool_info['parameters']}")
343
  result = call_mcp_tool(server_name, tool_info['tool_name'], **tool_info.get('parameters', {}))
344
 
345
- response_message = f"I used the **{tool_info['tool_name']}** tool from **{server_name}**."
346
- if isinstance(result, dict) and result.get("message"):
347
- response_message += f" ({result.get('message')})"
348
- response_message += "\n\n"
349
-
350
- if isinstance(result, dict) and result.get("type") == "audio_b64":
351
- audio_html = f"<audio controls src=\"data:audio/wav;base64,{result.get('data')}\"></audio>"
352
- yield response_message + audio_html
353
- elif isinstance(result, dict) and "error" in result:
354
- result_str = f"Tool Error: {result.get('error')}"
355
- yield response_message + result_str
356
  elif isinstance(result, dict):
357
- result_str = f"Result:\n```json\n{json.dumps(result, indent=2)}\n```"
358
- yield response_message + result_str
359
  else:
360
- result_str = f"Result:\n{str(result)}"
361
- yield response_message + result_str
362
- return
363
  except Exception as e:
364
  print(f"Error executing MCP tool via natural language: {str(e)}")
365
- # yield f"Sorry, I encountered an error trying to use the tool: {str(e)}"
366
- # Fall through to normal LLM response if tool call fails here
367
- else:
368
- print("No tool call detected by natural language analysis or tool_info incomplete.")
369
-
370
 
371
- user_content_parts = []
372
  if message and message.strip():
373
- user_content_parts.append({"type": "text", "text": message})
374
 
375
  if image_files and len(image_files) > 0:
376
  for img_path in image_files:
377
- if img_path:
378
  try:
379
  encoded_image = encode_image(img_path)
380
  if encoded_image:
381
- user_content_parts.append({
382
  "type": "image_url",
383
  "image_url": {"url": f"data:image/jpeg;base64,{encoded_image}"}
384
  })
385
  except Exception as e:
386
- print(f"Error encoding image {img_path}: {e}")
387
 
388
- if not user_content_parts: # If message was only /mcp command and processed
389
- print("No further content for LLM after MCP command processing.")
390
- # This might happen if an MCP command was fully handled and returned.
391
- # If yield was used, the function already exited. If not, we might need to ensure no LLM call.
392
- # However, the logic above for MCP commands uses `yield ...; return`, so this path might not be hit often.
393
- # If it *is* hit, it means the MCP command didn't yield, and we should not proceed to LLM.
394
- if message and message.startswith("/mcp"):
395
- return # Ensure we don't fall through after a command that should have yielded.
396
 
397
 
398
- final_user_content = user_content_parts if len(user_content_parts) > 1 else (user_content_parts[0] if user_content_parts else "")
399
-
400
  augmented_system_message = system_message
401
  if mcp_enabled and active_mcp_servers:
402
- tool_list_for_prompt = []
403
- for server_name_iter in active_mcp_servers:
404
- if server_name_iter in mcp_connections:
405
- server_tools_str = list_mcp_tools(server_name_iter)
406
- if server_tools_str and "not connected" not in server_tools_str and "No tools available" not in server_tools_str:
407
- tool_list_for_prompt.append(f"From server '{server_name_iter}':\n{server_tools_str}")
 
 
 
 
408
 
409
- if tool_list_for_prompt:
410
- mcp_tools_description = "\n\n".join(tool_list_for_prompt)
411
 
 
 
 
412
  if mcp_interaction_mode == "Command Mode":
413
- augmented_system_message += f"\n\nYou have access to the following MCP tools. To use them, type a command in the format: /mcp <server_name> <tool_name> <arguments_json>\nTools:\n{mcp_tools_description}"
414
  else: # Natural Language
415
- augmented_system_message += f"\n\nYou have access to the following MCP tools. You can ask to use them in natural language, and I will try to detect when a tool is needed. If I miss it, you can try being more explicit about the tool name.\nTools:\n{mcp_tools_description}"
416
-
417
- messages_for_api = [{"role": "system", "content": augmented_system_message}]
 
418
  print("Initial messages array constructed.")
419
 
420
- for val in history:
421
- past_user_msg, past_assistant_msg = val
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
- # Handle past user messages (could be text or multimodal)
424
- if past_user_msg:
425
- if isinstance(past_user_msg, list): # Already multimodal
426
- messages_for_api.append({"role": "user", "content": past_user_msg})
427
- elif isinstance(past_user_msg, str): # Text only
428
- messages_for_api.append({"role": "user", "content": past_user_msg})
429
 
430
- if past_assistant_msg:
431
- messages_for_api.append({"role": "assistant", "content": past_assistant_msg})
 
 
 
 
432
 
433
- if final_user_content: # Add current user message if it exists
434
- messages_for_api.append({"role": "user", "content": final_user_content})
435
-
436
- print(f"Latest user message appended (content type: {type(final_user_content)})")
437
- # print(f"Full messages_for_api: {json.dumps(messages_for_api, indent=2)}") # Can be very verbose
438
 
439
- llm_response_text = ""
440
- print(f"Sending request to {provider} provider for model {model_to_use}.")
 
 
 
 
441
 
442
  parameters = {
443
  "max_tokens": max_tokens,
@@ -450,39 +412,37 @@ def respond(
450
  parameters["seed"] = seed
451
 
452
  try:
453
- stream = client.chat_completion(
454
  model=model_to_use,
455
- messages=messages_for_api,
456
  stream=True,
457
  **parameters
458
  )
459
 
460
- # print("Received tokens: ", end="", flush=True) # Can be too noisy
461
 
462
  for chunk in stream:
463
  if hasattr(chunk, 'choices') and len(chunk.choices) > 0:
464
  if hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
465
  token_text = chunk.choices[0].delta.content
466
  if token_text:
467
- # print(token_text, end="", flush=True) # Can be too noisy
468
- llm_response_text += token_text
469
- yield llm_response_text
470
-
471
- # print() # Newline after tokens
472
  except Exception as e:
473
  print(f"Error during LLM inference: {e}")
474
- llm_response_text += f"\nLLM Error: {str(e)}"
475
- yield llm_response_text
476
 
477
  print("Completed LLM response generation.")
478
 
479
-
480
  # GRADIO UI
481
  with gr.Blocks(theme="Nymbo/Nymbo_Theme") as demo:
482
  chatbot = gr.Chatbot(
483
  height=600,
484
  show_copy_button=True,
485
- placeholder="Select a model and begin chatting. Supports multiple inference providers, multimodal inputs, and MCP tools.",
486
  layout="panel",
487
  show_label=False,
488
  render=False # Delay rendering
@@ -493,7 +453,7 @@ with gr.Blocks(theme="Nymbo/Nymbo_Theme") as demo:
493
  msg = gr.MultimodalTextbox(
494
  placeholder="Type a message or upload images...",
495
  show_label=False,
496
- container=False,
497
  scale=12,
498
  file_types=["image"],
499
  file_count="multiple",
@@ -501,238 +461,143 @@ with gr.Blocks(theme="Nymbo/Nymbo_Theme") as demo:
501
  render=False # Delay rendering
502
  )
503
 
 
504
  chatbot.render()
505
  msg.render()
506
 
507
  with gr.Accordion("Settings", open=False):
508
  system_message_box = gr.Textbox(
509
- value="You are a helpful AI assistant that can understand images and text. If the user asks you to use a tool, try your best.",
510
  placeholder="You are a helpful assistant.",
511
  label="System Prompt"
512
  )
513
 
514
  with gr.Row():
515
- with gr.Column(scale=1):
516
- max_tokens_slider = gr.Slider(minimum=1, maximum=8192, value=1024, step=1, label="Max tokens")
517
- temperature_slider = gr.Slider(minimum=0.0, maximum=2.0, value=0.7, step=0.01, label="Temperature")
518
- top_p_slider = gr.Slider(minimum=0.0, maximum=1.0, value=0.95, step=0.01, label="Top-P")
519
- with gr.Column(scale=1):
520
  frequency_penalty_slider = gr.Slider(minimum=-2.0, maximum=2.0, value=0.0, step=0.1, label="Frequency Penalty")
521
  seed_slider = gr.Slider(minimum=-1, maximum=65535, value=-1, step=1, label="Seed (-1 for random)")
522
 
523
  providers_list = ["hf-inference", "cerebras", "together", "sambanova", "novita", "cohere", "fireworks-ai", "hyperbolic", "nebius"]
524
  provider_radio = gr.Radio(choices=providers_list, value="hf-inference", label="Inference Provider")
525
- byok_textbox = gr.Textbox(value="", label="BYOK (Bring Your Own Key)", info="Enter a custom Hugging Face API key here. If empty, only 'hf-inference' provider can be used with the shared token.", placeholder="Enter your Hugging Face API token", type="password")
526
- custom_model_box = gr.Textbox(value="", label="Custom Model ID", info="(Optional) Provide a custom Hugging Face model ID. Overrides selected featured model.", placeholder="meta-llama/Llama-3.1-70B-Instruct")
527
-
528
- model_search_box = gr.Textbox(label="Filter Featured Models", placeholder="Search for a featured model...", lines=1)
529
 
530
  models_list = [
531
- "meta-llama/Llama-3.1-405B-Instruct-FP8", # Large model, might be slow/expensive
532
- "meta-llama/Llama-3.1-70B-Instruct",
533
- "meta-llama/Llama-3.1-8B-Instruct",
534
- "mistralai/Mistral-Nemo-Instruct-2407",
535
- "Qwen/Qwen2-72B-Instruct",
536
- "Qwen/Qwen2-57B-A14B-Instruct",
 
537
  "CohereForAI/c4ai-command-r-plus",
538
- # Multimodal models
539
- "Salesforce/LlavaLlama3-8b-hf",
540
- "llava-hf/llava-v1.6-mistral-7b-hf",
541
- "llava-hf/llava-v1.6-vicuna-13b-hf",
542
- "microsoft/Phi-3-vision-128k-instruct",
543
- "google/paligemma-3b-mix-448",
544
- # Older but still popular
545
- "NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO",
546
- "mistralai/Mixtral-8x7B-Instruct-v0.1",
547
- "mistralai/Mistral-7B-Instruct-v0.3",
548
- ]
549
- featured_model_radio = gr.Radio(label="Select a Featured Model", choices=models_list, value="meta-llama/Llama-3.1-8B-Instruct", interactive=True)
550
- gr.Markdown("[View all Text-to-Text models](https://huggingface.co/models?pipeline_tag=text-generation&sort=trending) | [View all multimodal models](https://huggingface.co/models?pipeline_tag=image-to-text&sort=trending)")
551
 
552
  with gr.Accordion("MCP Settings", open=False):
553
- mcp_enabled_checkbox = gr.Checkbox(label="Enable MCP Support", value=False, info="Enable Model Context Protocol support to connect to external tools and services")
554
  with gr.Row():
555
- mcp_server_url = gr.Textbox(label="MCP Server URL", placeholder="https://your-mcp-server.hf.space/gradio_api/mcp/sse", info="URL of the MCP server (usually ends with /gradio_api/mcp/sse for Gradio MCP servers)")
556
- mcp_server_name = gr.Textbox(label="Server Name (Optional)", placeholder="e.g., kokoroTTS", info="A friendly name to identify this server")
557
  mcp_connect_button = gr.Button("Connect to MCP Server")
558
-
559
  mcp_status = gr.Textbox(label="MCP Connection Status", placeholder="No MCP servers connected", interactive=False)
560
- active_mcp_servers = gr.Dropdown(label="Active MCP Servers for Chat", choices=[], multiselect=True, info="Select which connected MCP servers to make available to the LLM for this chat session")
561
- mcp_mode = gr.Radio(label="MCP Interaction Mode", choices=["Natural Language", "Command Mode"], value="Natural Language", info="Choose how to interact with MCP tools")
562
-
563
  gr.Markdown("""
564
- ### MCP Interaction Modes & Examples
565
- **Natural Language Mode**: Describe what you want.
566
- `Please say 'Hello world' using the kokoroTTS server.`
567
- `Use my speech tool to read this: "Welcome"`
568
-
569
- **Command Mode**: Use structured commands (server name must match connected server's friendly name).
570
- `/mcp <server_name> <tool_name> {"param1": "value1"}`
571
- Example: `/mcp kokoroTTS text_to_audio {"text": "Hello world", "speed": 1.0}`
572
  """)
573
 
574
- # Chat history state
575
- # The chatbot component itself manages history for display.
576
- # The `respond` function receives this display history and reconstructs API history.
577
-
578
- def filter_models_ui_update(search_term):
579
  print(f"Filtering models with search term: {search_term}")
 
580
  filtered = [m for m in models_list if search_term.lower() in m.lower()]
581
- if not filtered: # If search yields no results, show all models
582
- filtered = models_list
583
  print(f"Filtered models: {filtered}")
584
- return gr.Radio(choices=filtered, label="Select a Featured Model", value=featured_model_radio.value if featured_model_radio.value in filtered else (filtered[0] if filtered else None))
585
 
586
- def set_custom_model_from_radio_ui_update(selected_featured_model):
587
  print(f"Featured model selected: {selected_featured_model}")
588
- return selected_featured_model # This updates the custom_model_box
589
-
590
- def connect_mcp_server_ui_update(url, name_optional):
591
- actual_name, status_msg = connect_to_mcp_server(url, name_optional)
592
- updated_server_choices = list(mcp_connections.keys())
593
- # Keep existing selection if possible
 
 
 
 
 
 
594
  current_selection = active_mcp_servers.value if active_mcp_servers.value else []
595
- valid_selection = [s for s in current_selection if s in updated_server_choices]
596
- if actual_name and actual_name not in valid_selection: # Auto-select newly connected server
597
- valid_selection.append(actual_name)
598
-
599
- return status_msg, gr.Dropdown(choices=updated_server_choices, value=valid_selection, label="Active MCP Servers for Chat")
600
-
601
- # This function processes the user's multimodal input and adds it to the chatbot history.
602
- # It prepares the history in a way that `bot` can understand.
603
- def handle_user_input(multimodal_input, history_list: list):
604
- text_content = multimodal_input.get("text", "").strip()
605
- files = multimodal_input.get("files", [])
 
 
606
 
607
- # This will be the entry for the user's turn in the history
608
- user_turn_for_api = []
609
- user_turn_for_display = ""
610
-
611
- if text_content:
612
- user_turn_for_api.append({"type": "text", "text": text_content})
613
- user_turn_for_display = text_content
614
 
 
 
 
615
  if files:
616
- display_files_md = ""
617
  for file_path in files:
618
- if file_path and isinstance(file_path, str): # Gradio provides temp path
619
- encoded_img = encode_image(file_path) # For API
620
- if encoded_img:
621
- user_turn_for_api.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{encoded_img}"}})
622
- # For display, Gradio handles showing the image from MultimodalTextbox output
623
- # We'll just make a note in the display string
624
- display_files_md += f"\n<img src='file={file_path}' style='max-height:150px; display:block;' alt='uploaded image'>" # Gradio can render this!
625
-
626
- if user_turn_for_display:
627
- user_turn_for_display += display_files_md
628
- else:
629
- user_turn_for_display = display_files_md if display_files_md else "Image(s) uploaded"
630
-
631
 
632
- if not user_turn_for_display and not user_turn_for_api: # Empty input
633
- return history_list, multimodal_input # No change
634
-
635
- # The `respond` function expects history as list of [user_api_content, assistant_text_content]
636
- # For the current turn, we add [user_api_content, None]
637
- # The display history for chatbot is [user_display_content, assistant_text_content]
638
-
639
- # We pass the API-formatted user turn to the `message` arg of `respond`
640
- # and the existing history to the `history` arg.
641
- # The chatbot's display history is updated here.
642
-
643
- history_list.append([user_turn_for_display, None])
644
- return history_list, user_turn_for_api # Return updated history and the API formatted current message
645
 
646
 
647
- # The bot function that calls `respond` generator
648
- def call_bot_responder(history_list_for_display, current_user_api_content, sys_msg, max_tok, temp, top_p_val, freq_pen, seed_val, prov, api_key_val, cust_model, _search, sel_model, mcp_on, active_servs, mcp_inter_mode):
649
- if not current_user_api_content and not (history_list_for_display and history_list_for_display[-1][0]):
650
- print("Bot called with no current message and no history, skipping.")
651
- yield history_list_for_display # No change
 
 
 
 
 
652
  return
653
 
654
- # Reconstruct API history from display history
655
- # `respond` expects history as list of [user_api_content, assistant_text_content]
656
- # The current `history_list_for_display` is [user_display, assistant_text]
657
- # This reconstruction is tricky because display != api format.
658
- # For simplicity, we'll pass only the text part of history to `respond` for now,
659
- # and the full current_user_api_content for the current message.
660
- # A more robust solution would store API history separately.
661
-
662
- # Simplified history for `respond` (text only from past turns)
663
- # The `respond` function itself needs to be robust to handle this.
664
- # Let's adjust `respond` to take `message` (current API content) and `image_files` (current files)
665
- # and `history` (past turns, which we'll simplify here).
666
-
667
- # The `respond` function is already structured to take `message` (text) and `image_files`
668
- # The `current_user_api_content` is what we need to pass as `message` (if text) or `image_files`
669
-
670
- current_message_text = ""
671
- current_image_paths = []
672
-
673
- if isinstance(current_user_api_content, list): # Multimodal
674
- for part in current_user_api_content:
675
- if part["type"] == "text":
676
- current_message_text = part["text"]
677
- elif part["type"] == "image_url":
678
- # We can't easily get back the path from base64 for `respond`'s current design
679
- # This indicates a slight mismatch. `respond` expects paths for current images.
680
- # For now, let's assume `respond` can handle base64 if passed correctly.
681
- # Or, we modify `handle_user_input` to also pass original paths if needed by `respond`.
682
- # Let's assume `respond`'s `image_files` param can take base64 strings for now.
683
- # This is a simplification.
684
- # The `encode_image` in `respond` expects paths.
685
- # For now, we'll pass None for image_files if it's already in current_user_api_content.
686
- # This part needs careful review of how `respond` handles current images.
687
- # The `respond` function's `image_files` parameter is for new uploads.
688
- # If `current_user_api_content` already has encoded images, `respond` should use that.
689
- # The `respond` function's first two args are `message` (text) and `image_files` (paths).
690
- # We need to extract these from `current_user_api_content`.
691
- pass # Images are part of `current_user_api_content` which is passed to `messages_for_api`
692
- elif isinstance(current_user_api_content, str): # Text only
693
- current_message_text = current_user_api_content
694
 
695
- # Simplified history for `respond` (text from display)
696
- # `respond` will reconstruct its own API history.
697
- simplified_past_history = []
698
- if len(history_list_for_display) > 1: # Exclude current turn
699
- for user_disp, assistant_text in history_list_for_display[:-1]:
700
- # Extract text from user_disp for simplified history
701
- user_text_for_hist = user_disp
702
- if isinstance(user_disp, str) and "<img src" in user_disp : # Basic check if it was image display
703
- # Try to find text part if any, otherwise empty
704
- lines = user_disp.splitlines()
705
- text_lines = [line for line in lines if not line.strip().startswith("<img")]
706
- user_text_for_hist = "\n".join(text_lines).strip() if text_lines else ""
707
-
708
- simplified_past_history.append([user_text_for_hist, assistant_text])
709
-
710
-
711
- # The `respond` function's first argument is `message` (current text)
712
- # and `image_files` (current image paths).
713
- # We need to extract these from `current_user_api_content` if it was prepared by `handle_user_input`.
714
- # For now, let's assume `respond` will get the full `current_user_api_content` via `messages_for_api`.
715
- # The first two args of `respond` are for the *current* turn's text and image paths.
716
-
717
- # Let's get current text and image paths from `current_user_api_content`
718
- # This is slightly redundant as `respond` also reconstructs this, but for clarity:
719
- _current_text_for_respond = ""
720
- _current_image_paths_for_respond = [] # `respond` expects paths
721
-
722
- if isinstance(current_user_api_content, list):
723
- for item in current_user_api_content:
724
- if item['type'] == 'text':
725
- _current_text_for_respond = item['text']
726
- # We can't get paths back from base64 easily.
727
- # This highlights that `respond` needs to be able to take already processed multimodal content.
728
- # For now, we'll assume `respond` internally uses the `messages_for_api` which has the full content.
729
- # So, we can pass `_current_text_for_respond` and `None` for image_files if images are already in API format.
730
-
731
-
732
- bot_response_stream = respond(
733
- message=_current_text_for_respond, # Current text
734
- image_files=None, # Assume images are handled by messages_for_api construction in respond
735
- history=simplified_past_history, # Past turns
736
  system_message=sys_msg,
737
  max_tokens=max_tok,
738
  temperature=temp,
@@ -742,57 +607,77 @@ with gr.Blocks(theme="Nymbo/Nymbo_Theme") as demo:
742
  provider=prov,
743
  custom_api_key=api_key_val,
744
  custom_model=cust_model,
745
- model_search_term="", # Not directly used by respond
746
- selected_model=sel_model,
747
  mcp_enabled=mcp_on,
748
  active_mcp_servers=active_servs,
749
- mcp_interaction_mode=mcp_inter_mode
750
- )
751
-
752
- for response_chunk in bot_response_stream:
753
- history_list_for_display[-1][1] = response_chunk
754
- yield history_list_for_display
755
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
756
 
757
- # This state will hold the API-formatted content of the current user message
758
- current_api_message_state = gr.State(None)
759
 
 
760
  msg.submit(
761
- handle_user_input,
762
- [msg, chatbot], # chatbot here is the history_list
763
- [chatbot, current_api_message_state] # Update history display and current_api_message_state
 
764
  ).then(
765
- call_bot_responder,
766
- [chatbot, current_api_message_state, system_message_box, max_tokens_slider, temperature_slider, top_p_slider,
767
  frequency_penalty_slider, seed_slider, provider_radio, byok_textbox, custom_model_box,
768
  model_search_box, featured_model_radio, mcp_enabled_checkbox, active_mcp_servers, mcp_mode],
769
- [chatbot] # Update chatbot display with streaming response
770
  ).then(
771
- lambda: gr.MultimodalTextbox(value={"text": "", "files": []}), # Clear MultimodalTextbox
772
  None,
773
- [msg]
 
774
  )
775
 
776
  mcp_connect_button.click(
777
- connect_mcp_server_ui_update,
778
  [mcp_server_url, mcp_server_name],
779
  [mcp_status, active_mcp_servers]
780
  )
781
 
782
- model_search_box.change(fn=filter_models_ui_update, inputs=model_search_box, outputs=featured_model_radio)
783
- featured_model_radio.change(fn=set_custom_model_from_radio_ui_update, inputs=featured_model_radio, outputs=custom_model_box)
784
-
785
- def validate_provider_ui_update(api_key, current_provider):
786
- if not api_key.strip() and current_provider != "hf-inference":
787
- gr.Info("No API key provided. Defaulting to 'hf-inference' provider.")
788
- return gr.Radio(value="hf-inference") # Update provider_radio
789
- return gr.Radio(value=current_provider) # No change needed or keep current
 
 
790
 
791
- byok_textbox.change(fn=validate_provider_ui_update, inputs=[byok_textbox, provider_radio], outputs=provider_radio)
792
- provider_radio.change(fn=validate_provider_ui_update, inputs=[byok_textbox, provider_radio], outputs=provider_radio)
793
 
794
  print("Gradio interface initialized.")
795
 
796
  if __name__ == "__main__":
797
  print("Launching the demo application.")
798
- demo.queue().launch(show_api=False, debug=False) # mcp_server=False as this is a client
 
5
  import base64
6
  from PIL import Image
7
  import io
8
+ import requests # Retained, though not directly used in the core logic shown for modification
9
  from smolagents.mcp_client import MCPClient
 
 
 
 
10
 
11
  ACCESS_TOKEN = os.getenv("HF_TOKEN")
12
  print("Access token loaded.")
 
56
  tools = client.get_tools()
57
 
58
  # Store the connection for later use
59
+ name = server_name or f"Server_{len(mcp_connections)}_{base64.urlsafe_b64encode(os.urandom(3)).decode()}" # Ensure unique name
60
  mcp_connections[name] = {"client": client, "tools": tools, "url": server_url}
61
 
62
  return name, f"Successfully connected to {name} with {len(tools)} available tools"
 
82
  def call_mcp_tool(server_name, tool_name, **kwargs):
83
  """Call a specific tool from an MCP server"""
84
  if server_name not in mcp_connections:
85
+ return f"Server '{server_name}' not connected"
86
 
87
+ client = mcp_connections[server_name]["client"]
88
+ tools = mcp_connections[server_name]["tools"]
 
89
 
90
  # Find the requested tool
91
+ tool = next((t for t in tools if t.name == tool_name), None)
92
  if not tool:
93
+ return f"Tool '{tool_name}' not found on server '{server_name}'"
94
+
95
  try:
96
  # Call the tool with provided arguments
97
+ # The mcp_client's call_tool is expected to return the direct result from the tool
98
+ result = client.call_tool(tool_name, kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ # The result here could be a string (e.g. base64 audio), a dict, or other types
101
+ # depending on the MCP tool. The `respond` function will handle formatting.
102
+ return result
 
 
 
 
 
 
 
 
103
  except Exception as e:
104
  print(f"Error calling MCP tool: {e}")
105
+ return f"Error calling MCP tool: {str(e)}"
 
 
106
 
107
+ def analyze_message_for_tool_call(message, active_mcp_servers, client_for_llm, model_to_use, system_message_for_llm):
108
  """Analyze a message to determine if an MCP tool should be called"""
109
+ # Skip analysis if message is empty
110
  if not message or not message.strip():
111
  return None, None
112
 
113
+ # Get information about available tools
114
  tool_info = []
115
+ if active_mcp_servers:
116
+ for server_name in active_mcp_servers:
117
+ if server_name in mcp_connections:
118
+ server_tools = mcp_connections[server_name]["tools"]
119
+ for tool in server_tools:
120
+ tool_info.append({
121
+ "server_name": server_name,
122
+ "tool_name": tool.name,
123
+ "description": tool.description
124
+ })
 
 
 
 
125
 
126
  if not tool_info:
127
  return None, None
128
 
129
+ # Create a structured query for the LLM to analyze if a tool call is needed
130
  tools_desc = []
131
  for info in tool_info:
132
  tools_desc.append(f"{info['server_name']}.{info['tool_name']}: {info['description']}")
133
 
134
  tools_string = "\n".join(tools_desc)
135
 
136
+ # Updated prompt to guide LLM for TTS tool that returns base64
137
  analysis_system_prompt = f"""You are an assistant that helps determine if a user message requires using an external tool.
138
  Available tools:
139
  {tools_string}
 
144
  3. If yes, respond ONLY with a JSON object with "server_name", "tool_name", and "parameters".
145
  4. If no, respond ONLY with the exact string "NO_TOOL_NEEDED".
146
 
147
+ Example 1 (for TTS that returns base64 audio):
148
  User: "Please turn this text into speech: Hello world"
149
+ Response: {{"server_name": "kokoroTTS", "tool_name": "text_to_audio_b64", "parameters": {{"text": "Hello world", "speed": 1.0}}}}
150
 
151
+ Example 2 (for TTS with different speed):
152
+ User: "Read 'This is faster' at speed 1.5"
153
+ Response: {{"server_name": "kokoroTTS", "tool_name": "text_to_audio_b64", "parameters": {{"text": "This is faster", "speed": 1.5}}}}
154
 
155
+ Example 3 (general, non-tool):
156
  User: "What is the capital of France?"
157
  Response: NO_TOOL_NEEDED"""
158
 
159
  try:
160
+ # Call the LLM to analyze the message
161
+ response = client_for_llm.chat_completion(
162
  model=model_to_use,
163
  messages=[
164
  {"role": "system", "content": analysis_system_prompt},
165
  {"role": "user", "content": message}
166
  ],
167
+ temperature=0.1, # Low temperature for deterministic tool selection
168
  max_tokens=300
169
  )
170
 
171
  analysis = response.choices[0].message.content.strip()
172
+ print(f"Tool analysis raw response: '{analysis}'")
173
 
174
  if analysis == "NO_TOOL_NEEDED":
175
  return None, None
176
 
177
+ # Try to parse JSON directly from the response
178
  try:
179
  tool_call = json.loads(analysis)
180
+ return tool_call.get("server_name"), {
181
+ "tool_name": tool_call.get("tool_name"),
182
+ "parameters": tool_call.get("parameters", {})
183
+ }
184
+ except json.JSONDecodeError:
185
+ print(f"Failed to parse tool call JSON directly from: {analysis}")
186
+ # Fallback to extracting JSON if not a direct JSON response
187
+ json_start = analysis.find("{")
188
+ json_end = analysis.rfind("}") + 1
189
+
190
+ if json_start != -1 and json_end != 0 and json_end > json_start:
191
+ json_str = analysis[json_start:json_end]
192
+ try:
193
+ tool_call = json.loads(json_str)
194
+ return tool_call.get("server_name"), {
195
+ "tool_name": tool_call.get("tool_name"),
196
+ "parameters": tool_call.get("parameters", {})
197
+ }
198
+ except json.JSONDecodeError:
199
+ print(f"Failed to parse extracted tool call JSON: {json_str}")
200
+ return None, None
201
  else:
202
+ print(f"No JSON object found in analysis: {analysis}")
203
  return None, None
 
 
 
204
 
205
  except Exception as e:
206
  print(f"Error analyzing message for tool calls: {str(e)}")
 
227
  ):
228
  print(f"Received message: {message}")
229
  print(f"Received {len(image_files) if image_files else 0} images")
230
+ # print(f"History: {history}") # Can be very verbose
231
  print(f"System message: {system_message}")
232
  print(f"Max tokens: {max_tokens}, Temperature: {temperature}, Top-P: {top_p}")
233
  print(f"Frequency Penalty: {frequency_penalty}, Seed: {seed}")
 
247
  else:
248
  print("USING DEFAULT API KEY: Environment variable HF_TOKEN is being used for authentication")
249
 
250
+ client_for_llm = InferenceClient(token=token_to_use, provider=provider)
251
  print(f"Hugging Face Inference Client initialized with {provider} provider.")
252
 
253
  if seed == -1:
 
264
  return
265
 
266
  _, server_name, tool_name = command_parts[:3]
267
+ args_json_str = "{}" if len(command_parts) < 4 else command_parts[3]
268
 
269
  try:
270
+ args_dict = json.loads(args_json_str)
271
  result = call_mcp_tool(server_name, tool_name, **args_dict)
272
+
273
+ if "audio" in tool_name.lower() and "b64" in tool_name.lower() and isinstance(result, str):
274
+ audio_html = f'<audio controls src="data:audio/wav;base64,{result}"></audio>'
275
+ yield f"Executed {tool_name} from {server_name}.\n\nResult:\n{audio_html}"
 
276
  elif isinstance(result, dict):
277
  yield json.dumps(result, indent=2)
278
  else:
279
+ yield str(result)
280
+ return # MCP command handled, exit
281
  except json.JSONDecodeError:
282
+ yield f"Invalid JSON arguments: {args_json_str}"
283
  return
284
  except Exception as e:
285
  yield f"Error executing MCP command: {str(e)}"
286
  return
287
+ elif mcp_interaction_mode == "Natural Language" and active_mcp_servers:
 
288
  server_name, tool_info = analyze_message_for_tool_call(
289
+ message,
290
+ active_mcp_servers,
291
+ client_for_llm,
292
+ model_to_use,
293
+ system_message # Original system message for context, LLM uses its own for analysis
294
  )
295
 
296
  if server_name and tool_info and tool_info.get("tool_name"):
297
  try:
298
+ print(f"Calling tool via natural language: {server_name}.{tool_info['tool_name']} with parameters: {tool_info.get('parameters', {})}")
299
  result = call_mcp_tool(server_name, tool_info['tool_name'], **tool_info.get('parameters', {}))
300
 
301
+ tool_display_name = tool_info['tool_name']
302
+ if "audio" in tool_display_name.lower() and "b64" in tool_display_name.lower() and isinstance(result, str) and len(result) > 100: # Heuristic for base64 audio
303
+ audio_html = f'<audio controls src="data:audio/wav;base64,{result}"></audio>'
304
+ yield f"I used the {tool_display_name} tool from {server_name} with your request.\n\nResult:\n{audio_html}"
 
 
 
 
 
 
 
305
  elif isinstance(result, dict):
306
+ result_str = json.dumps(result, indent=2)
307
+ yield f"I used the {tool_display_name} tool from {server_name} with your request.\n\nResult:\n{result_str}"
308
  else:
309
+ result_str = str(result)
310
+ yield f"I used the {tool_display_name} tool from {server_name} with your request.\n\nResult:\n{result_str}"
311
+ return # MCP tool call handled via natural language
312
  except Exception as e:
313
  print(f"Error executing MCP tool via natural language: {str(e)}")
314
+ yield f"I tried to use a tool but encountered an error: {str(e)}. I will try to respond without it."
315
+ # Fall through to normal LLM response if tool call fails
 
 
 
316
 
317
+ user_content = []
318
  if message and message.strip():
319
+ user_content.append({"type": "text", "text": message})
320
 
321
  if image_files and len(image_files) > 0:
322
  for img_path in image_files:
323
+ if img_path is not None:
324
  try:
325
  encoded_image = encode_image(img_path)
326
  if encoded_image:
327
+ user_content.append({
328
  "type": "image_url",
329
  "image_url": {"url": f"data:image/jpeg;base64,{encoded_image}"}
330
  })
331
  except Exception as e:
332
+ print(f"Error encoding image for user content: {e}")
333
 
334
+ if not user_content: # If message was empty and no images, or only MCP command handled
335
+ if not message.startswith("/mcp"): # Avoid yielding empty if it was an MCP command
336
+ yield "" # Or handle appropriately, maybe return if no content
337
+ return
 
 
 
 
338
 
339
 
 
 
340
  augmented_system_message = system_message
341
  if mcp_enabled and active_mcp_servers:
342
+ tool_desc_list = []
343
+ for server_name_active in active_mcp_servers:
344
+ if server_name_active in mcp_connections:
345
+ # Get tools for this specific server
346
+ # Assuming list_mcp_tools returns a string like "- tool1: desc1\n- tool2: desc2"
347
+ server_tools_str = list_mcp_tools(server_name_active)
348
+ if server_tools_str != "Server not connected" and server_tools_str != "No tools available for this server":
349
+ for line in server_tools_str.split('\n'):
350
+ if line.startswith("- "):
351
+ tool_desc_list.append(f"{server_name_active}.{line[2:]}") # e.g., kokoroTTS.text_to_audio_b64: Convert text...
352
 
353
+ if tool_desc_list:
354
+ mcp_tools_description_for_llm = "\n".join(tool_desc_list)
355
 
356
+ # This informs the main LLM about available tools for general conversation,
357
+ # distinct from the specialized analyzer LLM.
358
+ # The main LLM doesn't call tools directly but can use this info to guide the user.
359
  if mcp_interaction_mode == "Command Mode":
360
+ augmented_system_message += f"\n\nYou have access to the following MCP tools which the user can invoke:\n{mcp_tools_description_for_llm}\n\nTo use these tools, the user can type a command in the format: /mcp <server_name> <tool_name> <arguments_json>"
361
  else: # Natural Language
362
+ augmented_system_message += f"\n\nYou have access to the following MCP tools. The system will try to use them automatically if the user's request matches their capability:\n{mcp_tools_description_for_llm}\n\nIf the user asks to do something a tool can do, the system will attempt to use it. For example, if a 'text_to_audio_b64' tool is available, and the user says 'read this text aloud', the system will try to use that tool."
363
+
364
+
365
+ messages_for_llm = [{"role": "system", "content": augmented_system_message}]
366
  print("Initial messages array constructed.")
367
 
368
+ for hist_user, hist_assistant in history:
369
+ # hist_user can be complex if it included images from MultimodalTextbox
370
+ # We need to reconstruct it properly for the LLM
371
+ current_hist_user_content = []
372
+ if isinstance(hist_user, dict) and 'text' in hist_user and 'files' in hist_user: # From MultimodalTextbox
373
+ if hist_user['text'] and hist_user['text'].strip():
374
+ current_hist_user_content.append({"type": "text", "text": hist_user['text']})
375
+ if hist_user['files']:
376
+ for img_file_path in hist_user['files']:
377
+ encoded_img = encode_image(img_file_path)
378
+ if encoded_img:
379
+ current_hist_user_content.append({
380
+ "type": "image_url",
381
+ "image_url": {"url": f"data:image/jpeg;base64,{encoded_img}"}
382
+ })
383
+ elif isinstance(hist_user, str): # Simple text history
384
+ current_hist_user_content.append({"type": "text", "text": hist_user})
385
 
386
+ if current_hist_user_content:
387
+ messages_for_llm.append({"role": "user", "content": current_hist_user_content})
 
 
 
 
388
 
389
+ if hist_assistant: # Assistant message is always text
390
+ # Check if assistant message was an HTML audio tag, if so, send a placeholder to LLM
391
+ if "<audio controls src=" in hist_assistant:
392
+ messages_for_llm.append({"role": "assistant", "content": "[Audio was played in response to the previous message]"})
393
+ else:
394
+ messages_for_llm.append({"role": "assistant", "content": hist_assistant})
395
 
 
 
 
 
 
396
 
397
+ messages_for_llm.append({"role": "user", "content": user_content})
398
+ print(f"Latest user message appended (content type: {type(user_content)})")
399
+ # print(f"Messages for LLM: {json.dumps(messages_for_llm, indent=2)}") # Very verbose
400
+
401
+ response_text = ""
402
+ print(f"Sending request to {provider} provider for general response.")
403
 
404
  parameters = {
405
  "max_tokens": max_tokens,
 
412
  parameters["seed"] = seed
413
 
414
  try:
415
+ stream = client_for_llm.chat_completion(
416
  model=model_to_use,
417
+ messages=messages_for_llm,
418
  stream=True,
419
  **parameters
420
  )
421
 
422
+ print("Streaming LLM response: ", end="", flush=True)
423
 
424
  for chunk in stream:
425
  if hasattr(chunk, 'choices') and len(chunk.choices) > 0:
426
  if hasattr(chunk.choices[0], 'delta') and hasattr(chunk.choices[0].delta, 'content'):
427
  token_text = chunk.choices[0].delta.content
428
  if token_text:
429
+ print(token_text, end="", flush=True)
430
+ response_text += token_text
431
+ yield response_text
432
+ print() # Newline after streaming
 
433
  except Exception as e:
434
  print(f"Error during LLM inference: {e}")
435
+ response_text += f"\nError during LLM response generation: {str(e)}"
436
+ yield response_text
437
 
438
  print("Completed LLM response generation.")
439
 
 
440
  # GRADIO UI
441
  with gr.Blocks(theme="Nymbo/Nymbo_Theme") as demo:
442
  chatbot = gr.Chatbot(
443
  height=600,
444
  show_copy_button=True,
445
+ placeholder="Select a model and begin chatting. Now supports multiple inference providers, multimodal inputs, and MCP tools",
446
  layout="panel",
447
  show_label=False,
448
  render=False # Delay rendering
 
453
  msg = gr.MultimodalTextbox(
454
  placeholder="Type a message or upload images...",
455
  show_label=False,
456
+ container=True, # Ensure it's a container for proper layout
457
  scale=12,
458
  file_types=["image"],
459
  file_count="multiple",
 
461
  render=False # Delay rendering
462
  )
463
 
464
+ # Render chatbot and message box after defining them
465
  chatbot.render()
466
  msg.render()
467
 
468
  with gr.Accordion("Settings", open=False):
469
  system_message_box = gr.Textbox(
470
+ value="You are a helpful AI assistant that can understand images and text.",
471
  placeholder="You are a helpful assistant.",
472
  label="System Prompt"
473
  )
474
 
475
  with gr.Row():
476
+ with gr.Column():
477
+ max_tokens_slider = gr.Slider(minimum=1, maximum=4096, value=512, step=1, label="Max tokens")
478
+ temperature_slider = gr.Slider(minimum=0.1, maximum=4.0, value=0.7, step=0.1, label="Temperature")
479
+ top_p_slider = gr.Slider(minimum=0.1, maximum=1.0, value=0.95, step=0.05, label="Top-P")
480
+ with gr.Column():
481
  frequency_penalty_slider = gr.Slider(minimum=-2.0, maximum=2.0, value=0.0, step=0.1, label="Frequency Penalty")
482
  seed_slider = gr.Slider(minimum=-1, maximum=65535, value=-1, step=1, label="Seed (-1 for random)")
483
 
484
  providers_list = ["hf-inference", "cerebras", "together", "sambanova", "novita", "cohere", "fireworks-ai", "hyperbolic", "nebius"]
485
  provider_radio = gr.Radio(choices=providers_list, value="hf-inference", label="Inference Provider")
486
+ byok_textbox = gr.Textbox(value="", label="BYOK (Bring Your Own Key)", info="Enter a custom Hugging Face API key here. If empty, only 'hf-inference' provider can be used with the default token.", placeholder="Enter your Hugging Face API token", type="password")
487
+ custom_model_box = gr.Textbox(value="", label="Custom Model", info="(Optional) Provide a Hugging Face model path. Overrides selected featured model.", placeholder="meta-llama/Llama-3.1-70B-Instruct")
488
+ model_search_box = gr.Textbox(label="Filter Models", placeholder="Search for a featured model...", lines=1)
 
489
 
490
  models_list = [
491
+ "meta-llama/Llama-3.2-11B-Vision-Instruct", "meta-llama/Llama-3.1-405B-Instruct", "meta-llama/Llama-3.1-70B-Instruct", "meta-llama/Llama-3.1-8B-Instruct",
492
+ "meta-llama/Llama-3-70B-Instruct", "meta-llama/Llama-3-8B-Instruct",
493
+ "NousResearch/Hermes-3-Llama-3.1-8B", "NousResearch/Nous-Hermes-2-Mixtral-8x7B-DPO",
494
+ "mistralai/Mistral-Nemo-Instruct-2407", "mistralai/Mixtral-8x7B-Instruct-v0.1", "mistralai/Mistral-7B-Instruct-v0.3", "mistralai/Mistral-7B-Instruct-v0.2",
495
+ "Qwen/Qwen2.5-72B-Instruct", "Qwen/Qwen2-72B-Instruct", "Qwen/Qwen2-57B-A14B-Instruct", "Qwen/Qwen1.5-110B-Chat",
496
+ "microsoft/Phi-3-medium-128k-instruct", "microsoft/Phi-3-mini-128k-instruct", "microsoft/Phi-3-small-128k-instruct",
497
+ "google/gemma-2-27b-it", "google/gemma-2-9b-it",
498
  "CohereForAI/c4ai-command-r-plus",
499
+ "deepseek-ai/DeepSeek-V2-Chat",
500
+ "Snowflake/snowflake-arctic-instruct"
501
+ ] # Keeping your original list, just formatted for readability
502
+ featured_model_radio = gr.Radio(label="Select a featured model", choices=models_list, value="meta-llama/Llama-3.2-11B-Vision-Instruct", interactive=True)
503
+ gr.Markdown("[View all Text-to-Text models](https://huggingface.co/models?pipeline_tag=text-generation&sort=trending) | [View all multimodal models](https://huggingface.co/models?pipeline_tag=image-text-to-text&sort=trending)")
 
 
 
 
 
 
 
 
504
 
505
  with gr.Accordion("MCP Settings", open=False):
506
+ mcp_enabled_checkbox = gr.Checkbox(label="Enable MCP Support", value=False, info="Enable Model Context Protocol support for external tools")
507
  with gr.Row():
508
+ mcp_server_url = gr.Textbox(label="MCP Server URL", placeholder="https://your-mcp-server.hf.space/gradio_api/mcp/sse")
509
+ mcp_server_name = gr.Textbox(label="Server Name (Optional)", placeholder="e.g., kokoroTTS")
510
  mcp_connect_button = gr.Button("Connect to MCP Server")
 
511
  mcp_status = gr.Textbox(label="MCP Connection Status", placeholder="No MCP servers connected", interactive=False)
512
+ active_mcp_servers = gr.Dropdown(label="Active MCP Servers for Chat", choices=[], multiselect=True, info="Select which connected MCP servers to use")
513
+ mcp_mode = gr.Radio(label="MCP Interaction Mode", choices=["Natural Language", "Command Mode"], value="Natural Language", info="How to trigger MCP tools")
 
514
  gr.Markdown("""
515
+ ### MCP Interaction Modes
516
+ **Natural Language**: Describe what you want. E.g., "Convert 'Hello' to speech".
517
+ **Command Mode**: Use `/mcp <server_name> <tool_name> {"param": "value"}`. E.g., `/mcp kokoroTTS text_to_audio_b64 {"text": "Hello world"}`.
 
 
 
 
 
518
  """)
519
 
520
+ chat_history_state = gr.State([]) # To store the actual history for the LLM
521
+
522
+ def filter_models_choices(search_term):
 
 
523
  print(f"Filtering models with search term: {search_term}")
524
+ if not search_term: return gr.update(choices=models_list)
525
  filtered = [m for m in models_list if search_term.lower() in m.lower()]
 
 
526
  print(f"Filtered models: {filtered}")
527
+ return gr.update(choices=filtered if filtered else models_list, value=featured_model_radio.value if featured_model_radio.value in filtered else (filtered[0] if filtered else models_list[0]))
528
 
529
+ def update_custom_model_from_radio(selected_featured_model):
530
  print(f"Featured model selected: {selected_featured_model}")
531
+ # This function now updates the custom_model_box.
532
+ # If you want the radio selection to BE the model_to_use unless custom_model_box has text,
533
+ # then custom_model_box should be cleared or its value used as override.
534
+ # For now, let's assume custom_model_box is an override.
535
+ # If you want the radio to directly feed into the selected_model parameter for respond(),
536
+ # then this function might not be needed or custom_model_box should be used as an override.
537
+ return selected_featured_model # This updates the custom_model_box with the radio selection.
538
+
539
+ def handle_connect_mcp_server(url, name_suggestion):
540
+ actual_name, status_msg = connect_to_mcp_server(url, name_suggestion)
541
+ all_server_names = list(mcp_connections.keys())
542
+ # Keep existing selections if possible
543
  current_selection = active_mcp_servers.value if active_mcp_servers.value else []
544
+ new_selection = [s for s in current_selection if s in all_server_names]
545
+ if actual_name and actual_name not in new_selection : # Auto-select newly connected server
546
+ new_selection.append(actual_name)
547
+ return status_msg, gr.update(choices=all_server_names, value=new_selection)
548
+
549
+ # This function is called when the user submits a message.
550
+ # It updates the visual chatbot history and prepares the state for the bot.
551
+ def handle_user_message(user_input_dict, current_chat_history_state):
552
+ text_content = user_input_dict.get("text", "").strip()
553
+ files = user_input_dict.get("files", []) # List of file paths
554
+
555
+ # Add to visual history (chatbot component)
556
+ visual_history_additions = []
557
 
558
+ # Store for LLM (chat_history_state)
559
+ # We store the raw dict from MultimodalTextbox for user messages
560
+ # to correctly reconstruct for the LLM later.
561
+ current_chat_history_state.append([user_input_dict, None])
 
 
 
562
 
563
+ # For visual chatbot, create separate entries for text and images
564
+ if text_content:
565
+ visual_history_additions.append([text_content, None])
566
  if files:
 
567
  for file_path in files:
568
+ visual_history_additions.append([ (file_path,), None]) # Gradio Chatbot expects tuple for files
 
 
 
 
 
 
 
 
 
 
 
 
569
 
570
+ return visual_history_additions, current_chat_history_state
 
 
 
 
 
 
 
 
 
 
 
 
571
 
572
 
573
+ # This function is called after user message is processed.
574
+ # It calls the LLM and streams the response.
575
+ def handle_bot_response(
576
+ current_chat_history_state, # This is the state with the latest user message
577
+ sys_msg, max_tok, temp, top_p_val, freq_pen, seed_val, prov, api_key_val, cust_model,
578
+ search, selected_feat_model, mcp_on, active_servs, mcp_interact_mode
579
+ ):
580
+ if not current_chat_history_state or current_chat_history_state[-1][1] is not None:
581
+ # User message not yet added or bot already responded
582
+ yield current_chat_history_state # Or some empty update
583
  return
584
 
585
+ # The user message is the first element of the last item in chat_history_state
586
+ # It's a dict: {'text': '...', 'files': ['path1', ...]}
587
+ user_message_dict = current_chat_history_state[-1][0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
 
589
+ text_from_user_dict = user_message_dict.get("text", "")
590
+ files_from_user_dict = user_message_dict.get("files", [])
591
+
592
+ # History for LLM should exclude the current un-responded user message
593
+ history_for_llm = current_chat_history_state[:-1]
594
+
595
+ # Stream response from LLM
596
+ full_response = ""
597
+ for R in respond(
598
+ message=text_from_user_dict,
599
+ image_files=files_from_user_dict,
600
+ history=history_for_llm, # Pass history BEFORE current turn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
601
  system_message=sys_msg,
602
  max_tokens=max_tok,
603
  temperature=temp,
 
607
  provider=prov,
608
  custom_api_key=api_key_val,
609
  custom_model=cust_model,
610
+ model_search_term=search, # This might be redundant if featured_model_radio directly updates custom_model_box
611
+ selected_model=selected_feat_model, # This is the value from the radio
612
  mcp_enabled=mcp_on,
613
  active_mcp_servers=active_servs,
614
+ mcp_interaction_mode=mcp_interact_mode
615
+ ):
616
+ full_response = R
617
+ # Update the last item in chat_history_state with bot's response
618
+ current_chat_history_state[-1][1] = full_response
619
+
620
+ # Update visual chatbot
621
+ # Need to reconstruct visual history from state
622
+ visual_history_update = []
623
+ for user_turn, bot_turn in current_chat_history_state:
624
+ # User turn processing
625
+ user_text_viz = user_turn.get("text", "")
626
+ user_files_viz = user_turn.get("files", [])
627
+ if user_text_viz:
628
+ visual_history_update.append([user_text_viz, None if bot_turn is None and user_turn == current_chat_history_state[-1][0] else bot_turn]) # Add text part
629
+ for f_path in user_files_viz:
630
+ visual_history_update.append([(f_path,), None if bot_turn is None and user_turn == current_chat_history_state[-1][0] else bot_turn]) # Add image part
631
+ # Bot turn processing if user turn was only text and no files
632
+ if not user_text_viz and not user_files_viz and user_text_viz == "" : # Should not happen with current logic
633
+ visual_history_update.append(["", bot_turn])
634
+ elif not user_files_viz and user_text_viz and bot_turn is not None and visual_history_update[-1][0] == user_text_viz :
635
+ visual_history_update[-1][1] = bot_turn # Assign bot response to the text part
636
+
637
+ yield visual_history_update, current_chat_history_state
638
 
 
 
639
 
640
+ # Event handlers
641
  msg.submit(
642
+ handle_user_message,
643
+ [msg, chat_history_state],
644
+ [chatbot, chat_history_state], # Update visual chatbot and state
645
+ queue=True # Use queue for streaming
646
  ).then(
647
+ handle_bot_response,
648
+ [chat_history_state, system_message_box, max_tokens_slider, temperature_slider, top_p_slider,
649
  frequency_penalty_slider, seed_slider, provider_radio, byok_textbox, custom_model_box,
650
  model_search_box, featured_model_radio, mcp_enabled_checkbox, active_mcp_servers, mcp_mode],
651
+ [chatbot, chat_history_state] # Update visual chatbot and state again with bot response
652
  ).then(
653
+ lambda: gr.update(value={"text": "", "files": []}), # Clear MultimodalTextbox
654
  None,
655
+ [msg],
656
+ queue=False # No queue for simple UI update
657
  )
658
 
659
  mcp_connect_button.click(
660
+ handle_connect_mcp_server,
661
  [mcp_server_url, mcp_server_name],
662
  [mcp_status, active_mcp_servers]
663
  )
664
 
665
+ model_search_box.change(fn=filter_models_choices, inputs=model_search_box, outputs=featured_model_radio)
666
+ # Let radio button directly be the selected_model, custom_model_box is an override
667
+ # featured_model_radio.change(fn=update_custom_model_from_radio, inputs=featured_model_radio, outputs=custom_model_box)
668
+
669
+
670
+ def validate_provider_choice(api_key_val, current_provider_val):
671
+ if not api_key_val.strip() and current_provider_val != "hf-inference":
672
+ gr.Info("No custom API key provided. Only 'hf-inference' provider can be used. Switching to 'hf-inference'.")
673
+ return gr.update(value="hf-inference")
674
+ return gr.update() # No change needed if valid or key provided
675
 
676
+ byok_textbox.change(fn=validate_provider_choice, inputs=[byok_textbox, provider_radio], outputs=provider_radio)
677
+ provider_radio.change(fn=validate_provider_choice, inputs=[byok_textbox, provider_radio], outputs=provider_radio)
678
 
679
  print("Gradio interface initialized.")
680
 
681
  if __name__ == "__main__":
682
  print("Launching the demo application.")
683
+ demo.queue().launch(show_api=False, mcp_server=False, share=os.environ.get("GRADIO_SHARE", "").lower() == "true")