Gustav2811 commited on
Commit
51fad1b
·
1 Parent(s): 0cce69c

Checkpoint 1 - Working UI

Browse files
.chainlit/config.toml CHANGED
@@ -66,7 +66,7 @@ edit_message = true
66
 
67
  [UI]
68
  # Name of the assistant.
69
- name = "Assistant"
70
 
71
  # default_theme = "dark"
72
 
 
66
 
67
  [UI]
68
  # Name of the assistant.
69
+ name = "Cars.AI.za"
70
 
71
  # default_theme = "dark"
72
 
.gitignore CHANGED
@@ -72,4 +72,6 @@ venv.bak/
72
  Thumbs.db
73
 
74
  # uv
75
- .uv/
 
 
 
72
  Thumbs.db
73
 
74
  # uv
75
+ .uv/
76
+
77
+ .files/
app.py CHANGED
@@ -3,32 +3,63 @@ import logging
3
  import sys
4
  import traceback
5
  from typing import Optional, Dict, List, Any
6
- import asyncio
7
  import json
8
  import chainlit as cl
9
- import google.generativeai as genai
10
  from openai import AsyncOpenAI
11
  from mcp import ClientSession
12
 
13
- # Configure the logger
14
  logging.basicConfig(
15
- level=os.environ.get(
16
- "LOGLEVEL", "DEBUG"
17
- ), # Default to DEBUG, configurable via environment
18
  stream=sys.stdout,
19
  format="%(asctime)s - %(levelname)s - %(message)s",
20
  )
21
 
22
- # Create logger instance
23
  log = logging.getLogger(__name__)
24
 
25
- log.info("Application script starting...")
26
-
27
  DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "gpt-4o")
28
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
29
 
30
  openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  @cl.oauth_callback
34
  def oauth_callback(
@@ -41,391 +72,307 @@ def oauth_callback(
41
 
42
  @cl.on_mcp_connect
43
  async def on_mcp_connect(connection, session: ClientSession):
44
- """
45
- Called when a new MCP connection is established.
46
- This version gracefully handles discovery of tools and prompts.
47
- """
48
  await cl.Message(
49
  content=f"Establishing connection with MCP server: `{connection.name}`..."
50
  ).send()
51
 
52
  try:
53
  result = await session.list_tools()
 
54
  if result and hasattr(result, "tools") and result.tools:
55
- tools_for_llm = [
56
- {
57
- "type": "function",
58
- "function": {
59
- "name": t.name,
60
- "description": t.description,
61
- "parameters": t.inputSchema,
62
- },
63
- }
64
- for t in result.tools
65
- ]
 
66
 
67
  all_mcp_tools = cl.user_session.get("mcp_tools", {})
68
  all_mcp_tools[connection.name] = tools_for_llm
69
  cl.user_session.set("mcp_tools", all_mcp_tools)
70
 
71
- tool_names = [t.name for t in result.tools]
72
- log.info(f"Tools loaded from {connection.name}: {', '.join(tool_names)}")
73
- await cl.Message(
74
- content=f"**Tools available from `{connection.name}`:**\n{', '.join(tool_names)}"
75
- ).send()
76
- except Exception as e:
77
- log.warning(f"Could not list tools for {connection.name}: {e}")
78
- log.debug(f"Full traceback for tools listing error: {traceback.format_exc()}")
79
-
80
- try:
81
- prompt_list_response = await session.send_request("prompts/list", {})
82
-
83
- if hasattr(prompt_list_response, "prompts") and prompt_list_response.prompts:
84
- all_mcp_prompts = cl.user_session.get("mcp_prompts", {})
85
- all_mcp_prompts[connection.name] = prompt_list_response.prompts
86
- cl.user_session.set("mcp_prompts", all_mcp_prompts)
87
-
88
- prompt_actions = [
89
- cl.Action(
90
- name="use_prompt",
91
- value=f"{connection.name}:{p.name}",
92
- label=f"/{p.name}",
93
- description=p.description or f"Execute the {p.name} prompt.",
94
- )
95
- for p in prompt_list_response.prompts
96
- ]
97
  log.info(
98
- f"Prompts loaded from {connection.name}: {[p.name for p in prompt_list_response.prompts]}"
99
  )
 
100
  await cl.Message(
101
- content=f"**Prompts available from `{connection.name}`:**",
102
- actions=prompt_actions,
103
  ).send()
 
 
 
104
  except Exception as e:
105
- log.warning(f"Could not list prompts for {connection.name}: {e}")
106
- log.debug(f"Full traceback for prompts listing error: {traceback.format_exc()}")
 
107
 
108
 
109
  @cl.on_mcp_disconnect
110
  async def on_mcp_disconnect(name: str, session: ClientSession):
111
- """Called when an MCP connection is terminated."""
112
- log.info(f"MCP connection {name} has been disconnected")
113
  await cl.Message(f"MCP connection `{name}` has been disconnected.").send()
 
114
  all_mcp_tools = cl.user_session.get("mcp_tools", {})
115
  if name in all_mcp_tools:
116
  del all_mcp_tools[name]
117
  cl.user_session.set("mcp_tools", all_mcp_tools)
118
 
119
- all_mcp_prompts = cl.user_session.get("mcp_prompts", {})
120
- if name in all_mcp_prompts:
121
- del all_mcp_prompts[name]
122
- cl.user_session.set("mcp_prompts", all_mcp_prompts)
123
-
124
 
125
  @cl.on_chat_start
126
  async def start():
127
  log.info("Chat session started")
128
  await cl.Message(content="# Welcome to Naked Insurance! How can I help?").send()
 
129
  cl.user_session.set(
130
  "message_history",
131
  [
132
  {
133
  "role": "system",
134
- "content": "You are a helpful AI assistant. Please provide accurate and helpful responses to user questions. You have access to tools. You should always understand the tools, and use these tools where possible first. ",
 
 
 
 
 
 
135
  }
136
  ],
137
  )
138
  cl.user_session.set("mcp_tools", {})
139
- cl.user_session.set("mcp_prompts", {})
140
-
141
 
142
- async def execute_tool_call(tool_call: Any):
143
- """
144
- Helper function to find the correct MCP session, execute a tool call,
145
- and robustly serialize the result for both the UI and the LLM.
146
- """
147
- tool_name = tool_call.function.name
148
- tool_args_str = tool_call.function.arguments
149
 
150
- history = cl.user_session.get("message_history")
151
-
152
- mcp_connection_name = None
153
- all_mcp_tools = cl.user_session.get("mcp_tools", {})
154
- for conn_name, tools in all_mcp_tools.items():
155
- if any(t["function"]["name"] == tool_name for t in tools):
156
- mcp_connection_name = conn_name
157
- break
158
-
159
- if not mcp_connection_name:
160
- error_msg = f"Tool {tool_name} not found in any active MCP connection"
161
- log.error(error_msg)
162
- history.append(
163
- {
164
- "tool_call_id": tool_call.id,
165
- "role": "tool",
166
- "name": tool_name,
167
- "content": f"Error: {error_msg}.",
168
- }
169
- )
170
- cl.user_session.set("message_history", history)
171
- return
172
-
173
- async with cl.Step(type="tool", name=tool_name) as step:
174
- step.input = json.loads(tool_args_str)
175
-
176
- try:
177
- mcp_session, _ = cl.context.session.mcp_sessions.get(mcp_connection_name)
178
- tool_args = json.loads(tool_args_str)
179
- tool_result = await mcp_session.call_tool(tool_name, tool_args)
180
-
181
- content = tool_result.content
182
- output_for_step = content # Default to showing raw content
183
- output_for_llm = str(content) # Default to string for LLM
184
-
185
- if (
186
- isinstance(content, list)
187
- and len(content) > 0
188
- and hasattr(content[0], "text")
189
- ):
190
- text_content = content[0].text
191
- output_for_llm = text_content # LLM always gets the original string
192
-
193
- # ✅ **THIS IS THE FIX**
194
- # Try to parse the text as JSON for a nice UI display.
195
- try:
196
- output_for_step = json.loads(text_content)
197
- except json.JSONDecodeError:
198
- # If it's not valid JSON, it's just plain text.
199
- output_for_step = text_content
200
-
201
- # Set the step output for the UI
202
- step.output = output_for_step
203
-
204
- # Append the string version to history for the LLM
205
- history.append(
206
- {
207
- "tool_call_id": tool_call.id,
208
- "role": "tool",
209
- "name": tool_name,
210
- "content": output_for_llm,
211
- }
212
- )
213
 
214
- except Exception as e:
215
- error_msg = f"Error executing tool `{tool_name}`: {traceback.format_exc()}"
216
- step.error = str(e)
217
- history.append(
218
- {
219
- "tool_call_id": tool_call.id,
220
- "role": "tool",
221
- "name": tool_name,
222
- "content": error_msg,
223
- }
224
- )
225
 
226
- cl.user_session.set("message_history", history)
227
 
 
 
 
 
228
 
229
- @cl.action_callback("use_prompt")
230
- async def use_prompt(action: cl.Action):
231
- """
232
- Handles the execution of a user-selected prompt with robust message parsing.
233
- """
234
  try:
235
- connection_name, prompt_name = action.value.split(":", 1)
236
- log.info(f"Executing prompt {prompt_name} from connection {connection_name}")
237
- except ValueError:
238
- error_msg = f"Invalid action value: {action.value}"
239
- log.error(error_msg)
240
- await cl.ErrorMessage(content=error_msg).send()
241
- return
242
-
243
- async with cl.Step(type="run", name=f"Prompt: /{prompt_name}") as step:
244
- try:
245
- mcp_session, _ = cl.context.session.mcp_sessions.get(connection_name)
246
- if not mcp_session:
247
- raise ValueError(f"No active MCP session found for '{connection_name}'")
248
-
249
- all_prompts = cl.user_session.get("mcp_prompts", {})
250
- prompt_def = next(
251
- (
252
- p
253
- for p in all_prompts.get(connection_name, [])
254
- if p.name == prompt_name
255
- ),
256
- None,
257
- )
258
-
259
- prompt_args = {}
260
- if prompt_def and hasattr(prompt_def, "arguments") and prompt_def.arguments:
261
- step.input = "Gathering arguments from user..."
262
- log.debug(
263
- f"Prompt {prompt_name} requires arguments: {[arg.name for arg in prompt_def.arguments]}"
264
- )
265
- for arg in prompt_def.arguments:
266
- if arg.required:
267
- res = await cl.AskUserMessage(
268
- content=f"Please provide a value for `{arg.name}`:\n_{arg.description or ''}_",
269
- timeout=180,
270
- ).send()
271
- if res:
272
- prompt_args[arg.name] = res["output"]
273
- else:
274
- log.warning(
275
- f"Prompt {prompt_name} cancelled due to timeout"
276
- )
277
- await cl.Message(
278
- content="Prompt cancelled due to timeout."
279
- ).send()
280
- return
281
-
282
- step.input = f"Connection: {connection_name}\nPrompt: {prompt_name}\nArguments: {prompt_args}"
283
-
284
- prompt_result = await mcp_session.send_request(
285
- "prompts/get", {"name": prompt_name, "arguments": prompt_args}
286
- )
287
-
288
- if not hasattr(prompt_result, "messages"):
289
- raise ValueError(
290
- "MCP server did not return valid messages for the prompt."
291
- )
292
-
293
- def message_to_dict(msg):
294
- role = getattr(msg, "role", "user")
295
- content_obj = getattr(msg, "content", "")
296
-
297
- if isinstance(content_obj, str):
298
- return {"role": role, "content": content_obj}
299
-
300
- if hasattr(content_obj, "text"):
301
- return {"role": role, "content": content_obj.text}
302
-
303
- return {"role": role, "content": str(content_obj)}
304
-
305
- llm_messages = [message_to_dict(msg) for msg in prompt_result.messages]
306
- log.debug(
307
- f"Generated {len(llm_messages)} messages from prompt {prompt_name}"
308
- )
309
-
310
- final_response_message = cl.Message(content="", author="Assistant")
311
- stream = await openai_client.chat.completions.create(
312
- model="gpt-4o", messages=llm_messages, stream=True
313
  )
314
 
315
- async for part in stream:
316
- if token := part.choices[0].delta.content or "":
317
- await final_response_message.stream_token(token)
318
-
319
- await final_response_message.update()
320
- step.output = final_response_message.content
321
- log.info(f"Prompt {prompt_name} executed successfully")
322
 
323
- except Exception as e:
324
- error_message = f"Error executing prompt /{prompt_name}: {str(e)}"
325
- log.error(error_message)
326
- log.error(f"Full traceback: {traceback.format_exc()}")
327
- step.error = error_message
328
- await cl.ErrorMessage(content=str(e)).send()
329
 
 
 
 
330
 
331
- @cl.on_message
332
- async def main(message: cl.Message):
333
- log.debug(f"Received message: {message.content[:100]}...") # Log first 100 chars
 
 
 
334
 
335
- history = cl.user_session.get("message_history")
336
- history.append({"role": "user", "content": message.content})
337
 
338
- all_mcp_tools = cl.user_session.get("mcp_tools", {})
339
- aggregated_tools = [
340
- tool for conn_tools in all_mcp_tools.values() for tool in conn_tools
341
- ]
342
 
343
- log.debug(f"Available tools: {len(aggregated_tools)}")
 
344
 
345
- try:
346
- stream = await openai_client.chat.completions.create(
347
- model="gpt-4o",
348
- messages=history,
349
- tools=aggregated_tools if aggregated_tools else None,
350
- stream=True,
351
- )
352
-
353
- output_message = cl.Message(content="", author="Assistant")
354
- tool_calls = []
355
- tool_calls_buffer = {}
356
-
357
- async for part in stream:
358
- delta = part.choices[0].delta
359
- if delta.content:
360
- await output_message.stream_token(delta.content)
361
-
362
- if delta.tool_calls:
363
- for tool_call_chunk in delta.tool_calls:
364
- index = tool_call_chunk.index
365
- if index not in tool_calls_buffer:
366
- tool_calls_buffer[index] = {
367
- "id": tool_call_chunk.id or "",
368
- "type": "function",
369
- "function": {
370
- "name": tool_call_chunk.function.name or "",
371
- "arguments": "",
372
- },
373
- }
374
-
375
- if tool_call_chunk.function.arguments:
376
- tool_calls_buffer[index]["function"][
377
- "arguments"
378
- ] += tool_call_chunk.function.arguments
379
-
380
- if output_message.content:
381
- await output_message.update()
382
-
383
- if tool_calls_buffer:
384
- log.info(f"Processing {len(tool_calls_buffer)} tool calls")
385
- assistant_message = {"role": "assistant", "content": None, "tool_calls": []}
386
- for index in sorted(tool_calls_buffer.keys()):
387
- assistant_message["tool_calls"].append(tool_calls_buffer[index])
388
-
389
- history.append(assistant_message)
390
-
391
- for tool_call_dict in assistant_message["tool_calls"]:
392
- from pydantic import BaseModel
393
-
394
- class Func(BaseModel):
395
- name: str
396
- arguments: str
397
-
398
- class ToolCall(BaseModel):
399
- id: str
400
- function: Func
401
- type: str
402
-
403
- tool_call_obj = ToolCall(**tool_call_dict)
404
- await execute_tool_call(tool_call_obj)
405
-
406
- final_stream = await openai_client.chat.completions.create(
407
- model="gpt-4o", messages=history, stream=True
408
- )
409
- final_output_message = cl.Message(content="", author="Assistant")
410
- async for part in final_stream:
411
- if token := part.choices[0].delta.content or "":
412
- await final_output_message.stream_token(token)
413
-
414
- await final_output_message.update()
415
- history.append(
416
- {"role": "assistant", "content": final_output_message.content}
417
- )
418
- log.debug("Tool calls processed and response generated")
419
 
420
- else:
421
- history.append({"role": "assistant", "content": output_message.content})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
 
423
  except Exception as e:
424
- error_msg = f"Error processing message: {str(e)}"
425
- log.error(error_msg)
426
- log.error(f"Full traceback: {traceback.format_exc()}")
427
- await cl.ErrorMessage(
428
- content="Sorry, an error occurred while processing your message. Please try again."
429
  ).send()
430
 
431
  cl.user_session.set("message_history", history)
 
3
  import sys
4
  import traceback
5
  from typing import Optional, Dict, List, Any
 
6
  import json
7
  import chainlit as cl
 
8
  from openai import AsyncOpenAI
9
  from mcp import ClientSession
10
 
 
11
  logging.basicConfig(
12
+ level=logging.INFO,
 
 
13
  stream=sys.stdout,
14
  format="%(asctime)s - %(levelname)s - %(message)s",
15
  )
16
 
 
17
  log = logging.getLogger(__name__)
18
 
 
 
19
  DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "gpt-4o")
20
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
21
 
22
  openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
23
 
24
+ # Local tools definition - defines the custom component as a tool the AI can use
25
+ local_tools = [
26
+ {
27
+ "type": "function",
28
+ "function": {
29
+ "name": "show_car_search_results",
30
+ "description": "IMPORTANT: Use this tool to display car search results in a visual, user-friendly component after a search is performed.",
31
+ "parameters": {
32
+ "type": "object",
33
+ "properties": {
34
+ "hits": {
35
+ "type": "array",
36
+ "description": "An array of car listing objects found in the search.",
37
+ "items": {"type": "object"},
38
+ },
39
+ "total_documents": {
40
+ "type": "number",
41
+ "description": "The total number of vehicles found.",
42
+ },
43
+ "search_time_ms": {
44
+ "type": "number",
45
+ "description": "The search execution time in milliseconds.",
46
+ },
47
+ "facet_counts": {
48
+ "type": "array",
49
+ "description": "Data for filtering, like brands and models.",
50
+ "items": {"type": "object"},
51
+ },
52
+ },
53
+ "required": [
54
+ "total_documents",
55
+ "search_time_ms",
56
+ "hits",
57
+ ],
58
+ },
59
+ },
60
+ }
61
+ ]
62
+
63
 
64
  @cl.oauth_callback
65
  def oauth_callback(
 
72
 
73
  @cl.on_mcp_connect
74
  async def on_mcp_connect(connection, session: ClientSession):
75
+ log.info(f"Establishing MCP connection: {connection.name}")
76
+
 
 
77
  await cl.Message(
78
  content=f"Establishing connection with MCP server: `{connection.name}`..."
79
  ).send()
80
 
81
  try:
82
  result = await session.list_tools()
83
+
84
  if result and hasattr(result, "tools") and result.tools:
85
+ tools_for_llm = []
86
+ for tool in result.tools:
87
+ tools_for_llm.append(
88
+ {
89
+ "type": "function",
90
+ "function": {
91
+ "name": tool.name,
92
+ "description": tool.description,
93
+ "parameters": tool.inputSchema,
94
+ },
95
+ }
96
+ )
97
 
98
  all_mcp_tools = cl.user_session.get("mcp_tools", {})
99
  all_mcp_tools[connection.name] = tools_for_llm
100
  cl.user_session.set("mcp_tools", all_mcp_tools)
101
 
102
+ tool_names = [tool.name for tool in result.tools]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  log.info(
104
+ f"Loaded {len(tool_names)} tools from {connection.name}: {', '.join(tool_names)}"
105
  )
106
+
107
  await cl.Message(
108
+ content=f"**Tools available from `{connection.name}`:**\n{', '.join(tool_names)}"
 
109
  ).send()
110
+ else:
111
+ log.info(f"No tools available from {connection.name}")
112
+
113
  except Exception as e:
114
+ log.error(f"Failed to list tools for {connection.name}: {str(e)}")
115
+
116
+ log.info(f"MCP connection established: {connection.name}")
117
 
118
 
119
  @cl.on_mcp_disconnect
120
  async def on_mcp_disconnect(name: str, session: ClientSession):
121
+ log.info(f"MCP connection disconnected: {name}")
 
122
  await cl.Message(f"MCP connection `{name}` has been disconnected.").send()
123
+
124
  all_mcp_tools = cl.user_session.get("mcp_tools", {})
125
  if name in all_mcp_tools:
126
  del all_mcp_tools[name]
127
  cl.user_session.set("mcp_tools", all_mcp_tools)
128
 
 
 
 
 
 
129
 
130
  @cl.on_chat_start
131
  async def start():
132
  log.info("Chat session started")
133
  await cl.Message(content="# Welcome to Naked Insurance! How can I help?").send()
134
+
135
  cl.user_session.set(
136
  "message_history",
137
  [
138
  {
139
  "role": "system",
140
+ "content": """You are a helpful AI assistant for Naked Insurance. When a user asks for cars or vehicle searches, you MUST follow this exact workflow:
141
+
142
+ 1. First, use the 'search-cars' tool to find vehicles matching their criteria
143
+ 2. Then, you MUST immediately use the 'show_car_search_results' tool to display the results in a visual component
144
+ 3. Finally, provide a brief summary of what was found
145
+
146
+ For other queries, provide accurate and helpful responses. Always use available tools when they can help answer the user's question. If there are context tools like 'get-context' or 'vehicle-search-context', use them at the start to understand the available data.""",
147
  }
148
  ],
149
  )
150
  cl.user_session.set("mcp_tools", {})
 
 
151
 
 
 
 
 
 
 
 
152
 
153
+ async def show_car_search_results(
154
+ total_documents, search_time_ms, hits=None, facet_counts=None
155
+ ):
156
+ """Display car search results using the custom CarSearchResults component"""
157
+ props = {
158
+ "total_documents": total_documents,
159
+ "search_time_ms": search_time_ms,
160
+ "facet_counts": facet_counts or [],
161
+ "hits": hits or [],
162
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
+ car_results_element = cl.CustomElement(name="CarSearchResults", props=props)
165
+ await cl.Message(
166
+ content="", elements=[car_results_element], author="CarSearchResults"
167
+ ).send()
168
+ return f"Car search results displayed: {total_documents:,} vehicles found across {len(facet_counts or [])} categories with {len(hits or [])} detailed listings"
 
 
 
 
 
 
169
 
 
170
 
171
+ @cl.on_message
172
+ async def main(message: cl.Message):
173
+ history = cl.user_session.get("message_history")
174
+ history.append({"role": "user", "content": message.content})
175
 
 
 
 
 
 
176
  try:
177
+ while True: # Start of the autonomous loop
178
+ all_mcp_tools = cl.user_session.get("mcp_tools", {})
179
+ aggregated_tools = [
180
+ tool for conn_tools in all_mcp_tools.values() for tool in conn_tools
181
+ ]
182
+ aggregated_tools.extend(local_tools)
183
+
184
+ # First call to OpenAI
185
+ response = await openai_client.chat.completions.create(
186
+ model="gpt-4o",
187
+ messages=history,
188
+ tools=aggregated_tools if aggregated_tools else None,
189
+ tool_choice="auto",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  )
191
 
192
+ response_message = response.choices[0].message
 
 
 
 
 
 
193
 
194
+ if not response_message.tool_calls:
195
+ # If no tool calls, it's the final answer. Stream it to the user.
196
+ final_answer = response_message.content
 
 
 
197
 
198
+ # Stream the response for better UX
199
+ msg = cl.Message(content="")
200
+ await msg.send()
201
 
202
+ # Stream the content token by token
203
+ for i, char in enumerate(final_answer):
204
+ await msg.stream_token(char)
205
+ # Small delay for better streaming effect (optional)
206
+ if i % 10 == 0: # Every 10 characters
207
+ await cl.sleep(0.01)
208
 
209
+ await msg.update()
 
210
 
211
+ history.append({"role": "assistant", "content": final_answer})
212
+ break
 
 
213
 
214
+ # If there are tool calls, process them
215
+ history.append(response_message) # Add assistant's tool request to history
216
 
217
+ for tool_call in response_message.tool_calls:
218
+ tool_name = tool_call.function.name
219
+ tool_output = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
+ try:
222
+ tool_args = json.loads(tool_call.function.arguments)
223
+
224
+ # --- Start of New Logic ---
225
+ # Special, direct handling for the search-cars tool
226
+ if tool_name == "search-cars": # Use your actual search tool name
227
+ log.info(
228
+ f"Intercepting call to '{tool_name}' to manage data flow."
229
+ )
230
+ mcp_connection_name = next(
231
+ (
232
+ conn_name
233
+ for conn_name, tools in all_mcp_tools.items()
234
+ if any(
235
+ t["function"]["name"] == tool_name for t in tools
236
+ )
237
+ ),
238
+ None,
239
+ )
240
+
241
+ if mcp_connection_name:
242
+ mcp_session, _ = cl.context.session.mcp_sessions.get(
243
+ mcp_connection_name
244
+ )
245
+ result = await mcp_session.call_tool(tool_name, tool_args)
246
+
247
+ # Extract JSON data from MCP result
248
+ raw_json_output = None
249
+ if hasattr(result, "content") and result.content:
250
+ if len(result.content) > 0 and hasattr(
251
+ result.content[0], "text"
252
+ ):
253
+ raw_json_output = result.content[0].text
254
+ else:
255
+ log.warning(
256
+ f"Unexpected content structure: {result.content}"
257
+ )
258
+ else:
259
+ raw_json_output = str(result)
260
+
261
+ if not raw_json_output or raw_json_output.strip() == "":
262
+ log.error("Empty or null response from search tool")
263
+ tool_output = (
264
+ "Error: Search tool returned empty response."
265
+ )
266
+ else:
267
+ # Check if the response looks like an error message instead of JSON
268
+ if (
269
+ raw_json_output.startswith("Error")
270
+ or "Request failed" in raw_json_output
271
+ ):
272
+ log.warning(
273
+ f"Search tool returned error: {raw_json_output}"
274
+ )
275
+ tool_output = f"Search failed: {raw_json_output}"
276
+ else:
277
+ try:
278
+ # Parse the data and call the display component immediately
279
+ search_data = json.loads(raw_json_output)
280
+ # Limit to top 5 results
281
+ all_hits = search_data.get("hits", [])
282
+ top_5_hits = all_hits[:5]
283
+
284
+ await show_car_search_results(
285
+ total_documents=search_data.get("found", 0),
286
+ search_time_ms=search_data.get(
287
+ "search_time_ms", 0
288
+ ),
289
+ facet_counts=search_data.get(
290
+ "facet_counts", []
291
+ ),
292
+ hits=top_5_hits,
293
+ )
294
+ # Provide a simple confirmation message back to the AI
295
+ total_found = search_data.get("found", 0)
296
+ displayed_count = len(top_5_hits)
297
+ tool_output = f"Search complete. Showing top {displayed_count} of {total_found} cars found."
298
+ except json.JSONDecodeError as e:
299
+ log.error(f"JSON decode error: {e}")
300
+ log.error(
301
+ f"Raw output that failed to parse: '{raw_json_output}'"
302
+ )
303
+ tool_output = f"Search failed - invalid response format. Please try again with specific search criteria (e.g., 'BMW', 'Toyota under R200k', etc.)"
304
+ except Exception as e:
305
+ log.error(
306
+ f"Failed to parse search result and display component: {e}"
307
+ )
308
+ tool_output = f"Error: Failed to process search results - {str(e)}"
309
+ else:
310
+ tool_output = f"Error: Tool {tool_name} not found."
311
+
312
+ # Special handling for local component
313
+ elif tool_name == "show_car_search_results":
314
+ # This will now likely be called less often, but we keep the logic
315
+ # in case the AI decides to call it directly with its own data.
316
+ log.info(f"Executing local tool: {tool_name}")
317
+ await show_car_search_results(**tool_args)
318
+ tool_output = f"Successfully displayed {len(tool_args.get('hits',[]))} cars in the custom component."
319
+ else:
320
+ # Generic handling for MCP tools with cleaner connection lookup
321
+ log.info(f"Executing MCP tool: {tool_name}")
322
+
323
+ # Cleaner MCP connection lookup using next() generator expression
324
+ mcp_connection_name = next(
325
+ (
326
+ conn_name
327
+ for conn_name, tools in all_mcp_tools.items()
328
+ if any(
329
+ t["function"]["name"] == tool_name for t in tools
330
+ )
331
+ ),
332
+ None,
333
+ )
334
+
335
+ if mcp_connection_name:
336
+ mcp_session, _ = cl.context.session.mcp_sessions.get(
337
+ mcp_connection_name
338
+ )
339
+ result = await mcp_session.call_tool(tool_name, tool_args)
340
+ # Extract text from result if necessary
341
+ if (
342
+ hasattr(result, "content")
343
+ and result.content
344
+ and hasattr(result.content[0], "text")
345
+ ):
346
+ tool_output = result.content[0].text
347
+ else:
348
+ tool_output = str(result)
349
+ else:
350
+ tool_output = f"Error: Tool {tool_name} not found in any MCP connection."
351
+ # --- End of New Logic ---
352
+
353
+ except json.JSONDecodeError as e:
354
+ log.error(f"Invalid JSON arguments for tool {tool_name}: {e}")
355
+ tool_output = f"Error: Invalid arguments provided for tool {tool_name}. Please check the format."
356
+
357
+ except Exception as e:
358
+ log.error(f"Error executing tool {tool_name}: {e}")
359
+ tool_output = f"Error executing tool {tool_name}: {str(e)}"
360
+
361
+ # Add tool result to history for the next loop iteration
362
+ history.append(
363
+ {
364
+ "tool_call_id": tool_call.id,
365
+ "role": "tool",
366
+ "name": tool_name,
367
+ "content": tool_output,
368
+ }
369
+ )
370
 
371
  except Exception as e:
372
+ log.error(f"Error in main message loop: {e}")
373
+ traceback.print_exc()
374
+ await cl.Message(
375
+ content=f"I encountered an error while processing your request: {str(e)}. Please try again."
 
376
  ).send()
377
 
378
  cl.user_session.set("message_history", history)
jsconfig.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "paths": {
5
+ "@/*": ["./public/*"]
6
+ }
7
+ }
8
+ }
public/components/ui/badge.jsx ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { forwardRef } from "react"
2
+
3
+ const badgeVariants = {
4
+ default: "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
5
+ secondary: "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
6
+ destructive: "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
7
+ outline: "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 text-foreground"
8
+ }
9
+
10
+ const Badge = forwardRef(({ className = "", variant = "default", ...props }, ref) => {
11
+ return (
12
+ <div
13
+ ref={ref}
14
+ className={`${badgeVariants[variant]} ${className}`}
15
+ {...props}
16
+ />
17
+ )
18
+ })
19
+ Badge.displayName = "Badge"
20
+
21
+ export { Badge, badgeVariants }
public/components/ui/card.jsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { forwardRef } from "react"
2
+
3
+ const Card = forwardRef(({ className = "", ...props }, ref) => (
4
+ <div
5
+ ref={ref}
6
+ className={`rounded-lg border bg-card text-card-foreground shadow-sm ${className}`}
7
+ {...props}
8
+ />
9
+ ))
10
+ Card.displayName = "Card"
11
+
12
+ const CardHeader = forwardRef(({ className = "", ...props }, ref) => (
13
+ <div ref={ref} className={`flex flex-col space-y-1.5 p-6 ${className}`} {...props} />
14
+ ))
15
+ CardHeader.displayName = "CardHeader"
16
+
17
+ const CardTitle = forwardRef(({ className = "", ...props }, ref) => (
18
+ <h3
19
+ ref={ref}
20
+ className={`text-2xl font-semibold leading-none tracking-tight ${className}`}
21
+ {...props}
22
+ />
23
+ ))
24
+ CardTitle.displayName = "CardTitle"
25
+
26
+ const CardDescription = forwardRef(({ className = "", ...props }, ref) => (
27
+ <p ref={ref} className={`text-sm text-muted-foreground ${className}`} {...props} />
28
+ ))
29
+ CardDescription.displayName = "CardDescription"
30
+
31
+ const CardContent = forwardRef(({ className = "", ...props }, ref) => (
32
+ <div ref={ref} className={`p-6 pt-0 ${className}`} {...props} />
33
+ ))
34
+ CardContent.displayName = "CardContent"
35
+
36
+ const CardFooter = forwardRef(({ className = "", ...props }, ref) => (
37
+ <div ref={ref} className={`flex items-center p-6 pt-0 ${className}`} {...props} />
38
+ ))
39
+ CardFooter.displayName = "CardFooter"
40
+
41
+ export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
public/elements/CarSearchResults.jsx ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
2
+ import { Badge } from "@/components/ui/badge"
3
+ import { MapPin, Calendar, Gauge, Heart, MoreHorizontal } from "lucide-react"
4
+
5
+ export default function CarSearchResults(props) {
6
+ const formatPrice = (priceInCents) => {
7
+ return `R ${new Intl.NumberFormat().format(priceInCents / 100)}`
8
+ }
9
+
10
+ const formatMileage = (mileageInKm) => {
11
+ return `${new Intl.NumberFormat().format(mileageInKm)} km`
12
+ }
13
+
14
+ const getConditionColor = (condition) => {
15
+ const colors = {
16
+ 'excellent': 'bg-green-100 text-green-800',
17
+ 'good': 'bg-blue-100 text-blue-800',
18
+ 'fair': 'bg-yellow-100 text-yellow-800',
19
+ 'used': 'bg-gray-100 text-gray-800'
20
+ }
21
+ return colors[condition?.toLowerCase()]?.toUpperCase() || 'bg-gray-100 text-gray-800'
22
+ }
23
+
24
+ const calculateInsuranceQuote = (priceInCents, year, condition) => {
25
+ const vehicleValue = priceInCents / 100
26
+ const currentYear = new Date().getFullYear()
27
+ const age = currentYear - parseInt(year)
28
+
29
+ let baseRate = 0.08
30
+
31
+ if (age <= 3) baseRate = 0.06
32
+ else if (age <= 7) baseRate = 0.07
33
+ else if (age <= 12) baseRate = 0.08
34
+ else baseRate = 0.10
35
+
36
+ if (condition?.toLowerCase() === 'excellent') baseRate *= 0.9
37
+ else if (condition?.toLowerCase() === 'fair') baseRate *= 1.1
38
+
39
+ const monthlyQuote = (vehicleValue * baseRate) / 12
40
+ return Math.round(monthlyQuote)
41
+ }
42
+
43
+ const cars = props.hits || []
44
+
45
+ console.log(props);
46
+
47
+ return (
48
+ <div className="w-full max-w-4xl mx-auto space-y-4">
49
+ {/* Header */}
50
+ <div className="mb-6">
51
+ <h2 className="text-2xl font-bold text-gray-900">
52
+ {new Intl.NumberFormat().format(props.total_documents || 0)} vehicles found
53
+ </h2>
54
+ <p className="text-gray-600">
55
+ Search completed in {props.search_time_ms || 0}ms
56
+ </p>
57
+ </div>
58
+
59
+ {/* Car Listings */}
60
+ <div className="space-y-4">
61
+ {cars.map((car) => (
62
+ <Card key={car.document?.vehicle_listing_id} className="overflow-hidden hover:shadow-lg transition-shadow">
63
+ <div className="flex">
64
+ {/* Car Image */}
65
+ <div className="w-64 h-48 bg-gray-200 flex-shrink-0 relative">
66
+ {car.document.vehicle_listing_image_urls?.[0] ? (
67
+ <img
68
+ src={car.document.vehicle_listing_image_urls[0]}
69
+ alt={`${car.document.vehicle_make_brand} ${car.document.vehicle_model_name}`}
70
+ className="w-full h-full object-cover"
71
+ />
72
+ ) : (
73
+ <div className="w-full h-full flex items-center justify-center text-gray-400">
74
+ No Image
75
+ </div>
76
+ )}
77
+ <button className="absolute top-3 right-3 p-2 bg-white/80 rounded-full hover:bg-white transition-colors">
78
+ <Heart className="h-4 w-4 text-gray-600" />
79
+ </button>
80
+ </div>
81
+
82
+ {/* Car Details */}
83
+ <div className="flex-1 p-6">
84
+ <div className="flex justify-between items-start mb-4">
85
+ <div>
86
+ <h3 className="text-xl font-bold text-gray-900 mb-1 dark:text-gray-400">
87
+ {car.document.vehicle_manufacturing_year} {car.document.vehicle_make_brand} {car.document.vehicle_model_name}
88
+ </h3>
89
+ {car.document.vehicle_variant_full_name && (
90
+ <p className="text-gray-600 mb-2 dark:text-gray-400 text-sm">
91
+ {car.document.vehicle_variant_full_name}
92
+ </p>
93
+ )}
94
+ </div>
95
+ </div>
96
+
97
+ {/* Car Stats */}
98
+ <div className="flex items-center gap-6 mb-4 text-sm text-gray-600 dark:text-gray-400">
99
+ <div className="flex items-center gap-1">
100
+ <Gauge className="h-4 w-4" />
101
+ <span>{formatMileage(car.document.vehicle_mileage_in_km)}</span>
102
+ </div>
103
+
104
+ {car.document.vehicle_condition_status && (
105
+ <Badge className={getConditionColor(car.document.vehicle_condition_status)}>
106
+ {car.document.vehicle_condition_status}
107
+ </Badge>
108
+ )}
109
+
110
+ <div className="flex items-center gap-1">
111
+ <MapPin className="h-4 w-4" />
112
+ <span>{car.document.vehicle_location_suburb || car.document.vehicle_location_city}</span>
113
+ </div>
114
+ </div>
115
+
116
+ {/* Price and Insurance Quote */}
117
+ <div className="flex justify-between items-end">
118
+ <div>
119
+ <div className="text-2xl font-bold text-green-600 mb-2">
120
+ {formatPrice(car.document.vehicle_price_in_cents)}
121
+ </div>
122
+ <div className="flex flex-col gap-1">
123
+ <div className="text-sm text-blue-600 font-medium">
124
+ Insurance from R{new Intl.NumberFormat().format(
125
+ car.document.vehicle_indicative_quote/100
126
+ )}/month
127
+ </div>
128
+ <div className="flex items-center gap-1 text-xs text-gray-500">
129
+ <img
130
+ src="https://www.naked.insure/favicons/apple-icon-180x180.png"
131
+ alt="Naked Insurance"
132
+ className="h-4 w-auto"
133
+ />
134
+ <span className="font-medium">Naked Insurance</span>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </Card>
142
+ ))}
143
+ </div>
144
+
145
+ {/* No Results */}
146
+ {cars.length === 0 && (
147
+ <div className="text-center py-12">
148
+ <div className="text-gray-400 text-lg mb-2">No vehicles found</div>
149
+ <div className="text-gray-500">Try adjusting your search criteria</div>
150
+ </div>
151
+ )}
152
+ </div>
153
+ )
154
+ }