khoj 1.30.11.dev64__py3-none-any.whl → 1.32.3.dev34__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. khoj/configure.py +4 -2
  2. khoj/database/adapters/__init__.py +67 -58
  3. khoj/database/admin.py +9 -9
  4. khoj/database/migrations/0077_chatmodel_alter_agent_chat_model_and_more.py +62 -0
  5. khoj/database/migrations/0078_khojuser_email_verification_code_expiry.py +17 -0
  6. khoj/database/models/__init__.py +9 -8
  7. khoj/interface/compiled/404/index.html +1 -1
  8. khoj/interface/compiled/_next/static/chunks/182-8cd8b17d40e6e989.js +20 -0
  9. khoj/interface/compiled/_next/static/chunks/1915-605f698f2573cfd4.js +1 -0
  10. khoj/interface/compiled/_next/static/chunks/2117-9886e6a0232dc093.js +2 -0
  11. khoj/interface/compiled/_next/static/chunks/2581-455000f8aeb08fc3.js +1 -0
  12. khoj/interface/compiled/_next/static/chunks/3175-b2e522f8ca392f7e.js +3 -0
  13. khoj/interface/compiled/_next/static/chunks/3727.dcea8f2193111552.js +1 -0
  14. khoj/interface/compiled/_next/static/chunks/3789-a09e37a819171a9d.js +1 -0
  15. khoj/interface/compiled/_next/static/chunks/4124-0baa32400521e909.js +1 -0
  16. khoj/interface/compiled/_next/static/chunks/4357-03ea130575287c27.js +1 -0
  17. khoj/interface/compiled/_next/static/chunks/5243-f7f0a2a6e1ac5d28.js +1 -0
  18. khoj/interface/compiled/_next/static/chunks/5427-3e7360c8e6ac9728.js +1 -0
  19. khoj/interface/compiled/_next/static/chunks/{1279-4cb23143aa2c0228.js → 5473-b1cf56dedac6577a.js} +1 -1
  20. khoj/interface/compiled/_next/static/chunks/5477-c5d7eabee28a789a.js +1 -0
  21. khoj/interface/compiled/_next/static/chunks/6065-64db9ad305ba0bcd.js +1 -0
  22. khoj/interface/compiled/_next/static/chunks/8667-d3e5bc726e4ff4e3.js +1 -0
  23. khoj/interface/compiled/_next/static/chunks/9259-27d1ff42af9a43e0.js +1 -0
  24. khoj/interface/compiled/_next/static/chunks/94ca1967.1d9b42d929a1ee8c.js +1 -0
  25. khoj/interface/compiled/_next/static/chunks/{1210.ef7a0f9a7e43da1d.js → 9597.83583248dfbf6e73.js} +1 -1
  26. khoj/interface/compiled/_next/static/chunks/964ecbae.51d6faf8801d15e6.js +1 -0
  27. khoj/interface/compiled/_next/static/chunks/9665-1ab5c8c667b74dca.js +1 -0
  28. khoj/interface/compiled/_next/static/chunks/app/_not-found/{page-cfba071f5a657256.js → page-a834eddae3e235df.js} +1 -1
  29. khoj/interface/compiled/_next/static/chunks/app/agents/layout-e00fb81dca656a10.js +1 -0
  30. khoj/interface/compiled/_next/static/chunks/app/agents/page-ab5ebe4efba9b582.js +1 -0
  31. khoj/interface/compiled/_next/static/chunks/app/automations/{layout-7f1b79a2c67af0b4.js → layout-1fe1537449f43496.js} +1 -1
  32. khoj/interface/compiled/_next/static/chunks/app/automations/page-37d56a7bbfd307df.js +1 -0
  33. khoj/interface/compiled/_next/static/chunks/app/chat/layout-33934fc2d6ae6838.js +1 -0
  34. khoj/interface/compiled/_next/static/chunks/app/chat/page-a0b61f10b0bf6dd5.js +1 -0
  35. khoj/interface/compiled/_next/static/chunks/app/layout-30e7fda7262713ce.js +1 -0
  36. khoj/interface/compiled/_next/static/chunks/app/page-33a3375b1414d1bd.js +1 -0
  37. khoj/interface/compiled/_next/static/chunks/app/search/layout-c02531d586972d7d.js +1 -0
  38. khoj/interface/compiled/_next/static/chunks/app/search/page-bbbfda90fa03c5be.js +1 -0
  39. khoj/interface/compiled/_next/static/chunks/app/settings/layout-d09d6510a45cd4bd.js +1 -0
  40. khoj/interface/compiled/_next/static/chunks/app/settings/page-430db6215e48aea2.js +1 -0
  41. khoj/interface/compiled/_next/static/chunks/app/share/chat/layout-e8e5db7830bf3f47.js +1 -0
  42. khoj/interface/compiled/_next/static/chunks/app/share/chat/page-02dc1f2e2a41e522.js +1 -0
  43. khoj/interface/compiled/_next/static/chunks/d3ac728e-44ebd2a0c99b12a0.js +1 -0
  44. khoj/interface/compiled/_next/static/chunks/{fd9d1056-2e6c8140e79afc3b.js → fd9d1056-4482b99a36fd1673.js} +1 -1
  45. khoj/interface/compiled/_next/static/chunks/main-app-de1f09df97a3cfc7.js +1 -0
  46. khoj/interface/compiled/_next/static/chunks/main-db4bfac6b0a8d00b.js +1 -0
  47. khoj/interface/compiled/_next/static/chunks/pages/{_app-f870474a17b7f2fd.js → _app-3c9ca398d360b709.js} +1 -1
  48. khoj/interface/compiled/_next/static/chunks/pages/{_error-c66a4e8afc46f17b.js → _error-cf5ca766ac8f493f.js} +1 -1
  49. khoj/interface/compiled/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  50. khoj/interface/compiled/_next/static/chunks/webpack-b0a1b08bb62bdc15.js +1 -0
  51. khoj/interface/compiled/_next/static/css/0f04760e76bba6c1.css +25 -0
  52. khoj/interface/compiled/_next/static/css/37a73b87f02df402.css +1 -0
  53. khoj/interface/compiled/_next/static/css/8e6a3ca11a60b189.css +1 -0
  54. khoj/interface/compiled/_next/static/css/9c164d9727dd8092.css +1 -0
  55. khoj/interface/compiled/_next/static/css/c3acbadc30537d04.css +1 -0
  56. khoj/interface/compiled/_next/static/css/dac88c17aaee5fcf.css +1 -0
  57. khoj/interface/compiled/_next/static/css/df4b47a2d0d85eae.css +1 -0
  58. khoj/interface/compiled/_next/static/css/e546bf5cc4914244.css +1 -0
  59. khoj/interface/compiled/_next/static/mqcIHpVqVWkmBuN0npYHA/_buildManifest.js +1 -0
  60. khoj/interface/compiled/agents/index.html +1 -1
  61. khoj/interface/compiled/agents/index.txt +6 -6
  62. khoj/interface/compiled/automations/index.html +1 -1
  63. khoj/interface/compiled/automations/index.txt +7 -7
  64. khoj/interface/compiled/chat/index.html +1 -1
  65. khoj/interface/compiled/chat/index.txt +6 -6
  66. khoj/interface/compiled/index.html +1 -1
  67. khoj/interface/compiled/index.txt +6 -6
  68. khoj/interface/compiled/search/index.html +1 -1
  69. khoj/interface/compiled/search/index.txt +6 -6
  70. khoj/interface/compiled/settings/index.html +1 -1
  71. khoj/interface/compiled/settings/index.txt +8 -8
  72. khoj/interface/compiled/share/chat/index.html +1 -1
  73. khoj/interface/compiled/share/chat/index.txt +6 -6
  74. khoj/interface/email/magic_link.html +36 -13
  75. khoj/main.py +1 -1
  76. khoj/migrations/migrate_server_pg.py +7 -7
  77. khoj/processor/conversation/anthropic/anthropic_chat.py +5 -7
  78. khoj/processor/conversation/google/gemini_chat.py +5 -7
  79. khoj/processor/conversation/google/utils.py +0 -1
  80. khoj/processor/conversation/offline/chat_model.py +15 -14
  81. khoj/processor/conversation/openai/gpt.py +7 -9
  82. khoj/processor/conversation/openai/utils.py +31 -17
  83. khoj/processor/conversation/prompts.py +65 -49
  84. khoj/processor/conversation/utils.py +46 -44
  85. khoj/processor/tools/online_search.py +49 -2
  86. khoj/routers/api.py +22 -27
  87. khoj/routers/api_agents.py +4 -4
  88. khoj/routers/api_chat.py +33 -13
  89. khoj/routers/api_model.py +4 -4
  90. khoj/routers/auth.py +108 -7
  91. khoj/routers/email.py +10 -14
  92. khoj/routers/helpers.py +187 -143
  93. khoj/routers/web_client.py +1 -1
  94. khoj/utils/constants.py +1 -1
  95. khoj/utils/helpers.py +5 -3
  96. khoj/utils/initialization.py +28 -26
  97. {khoj-1.30.11.dev64.dist-info → khoj-1.32.3.dev34.dist-info}/METADATA +7 -7
  98. {khoj-1.30.11.dev64.dist-info → khoj-1.32.3.dev34.dist-info}/RECORD +102 -99
  99. {khoj-1.30.11.dev64.dist-info → khoj-1.32.3.dev34.dist-info}/WHEEL +1 -1
  100. khoj/interface/compiled/_next/static/67DcUiU9MqkM1fhksWunh/_buildManifest.js +0 -1
  101. khoj/interface/compiled/_next/static/chunks/1459.690bf20e7d7b7090.js +0 -1
  102. khoj/interface/compiled/_next/static/chunks/1603-13cef426e0e650ec.js +0 -1
  103. khoj/interface/compiled/_next/static/chunks/1970-1b63ac1497b03a10.js +0 -1
  104. khoj/interface/compiled/_next/static/chunks/2646-92ba433951d02d52.js +0 -20
  105. khoj/interface/compiled/_next/static/chunks/3072-be830e4f8412b9d2.js +0 -1
  106. khoj/interface/compiled/_next/static/chunks/3463-081c031e873b7966.js +0 -3
  107. khoj/interface/compiled/_next/static/chunks/3690-51312931ba1eae30.js +0 -1
  108. khoj/interface/compiled/_next/static/chunks/3717-b46079dbe9f55694.js +0 -1
  109. khoj/interface/compiled/_next/static/chunks/4504-62ac13e7d94c52f9.js +0 -1
  110. khoj/interface/compiled/_next/static/chunks/4602-460621c3241e0d13.js +0 -1
  111. khoj/interface/compiled/_next/static/chunks/4752-554a3db270186ce3.js +0 -1
  112. khoj/interface/compiled/_next/static/chunks/5512-7cc62049bbe60e11.js +0 -1
  113. khoj/interface/compiled/_next/static/chunks/5538-0ea2d3944ca051e1.js +0 -1
  114. khoj/interface/compiled/_next/static/chunks/7023-e8de2bded4df6539.js +0 -2
  115. khoj/interface/compiled/_next/static/chunks/7592-a09c39a38e60634b.js +0 -1
  116. khoj/interface/compiled/_next/static/chunks/8423-1dda16bc56236523.js +0 -1
  117. khoj/interface/compiled/_next/static/chunks/94ca1967.5584df65931cfe83.js +0 -1
  118. khoj/interface/compiled/_next/static/chunks/964ecbae.ea4eab2a3a835ffe.js +0 -1
  119. khoj/interface/compiled/_next/static/chunks/app/agents/layout-1878cc328ea380bd.js +0 -1
  120. khoj/interface/compiled/_next/static/chunks/app/agents/page-8eead7920b0ff92a.js +0 -1
  121. khoj/interface/compiled/_next/static/chunks/app/automations/page-b5800b5286306140.js +0 -1
  122. khoj/interface/compiled/_next/static/chunks/app/chat/layout-9219a85f3477e722.js +0 -1
  123. khoj/interface/compiled/_next/static/chunks/app/chat/page-d7d2ab93e519f0b2.js +0 -1
  124. khoj/interface/compiled/_next/static/chunks/app/layout-6310c57b674dd6f5.js +0 -1
  125. khoj/interface/compiled/_next/static/chunks/app/page-3c32ad5472f75965.js +0 -1
  126. khoj/interface/compiled/_next/static/chunks/app/search/layout-2ca475462c0b2176.js +0 -1
  127. khoj/interface/compiled/_next/static/chunks/app/search/page-faa998c71eb7ca8e.js +0 -1
  128. khoj/interface/compiled/_next/static/chunks/app/settings/layout-f285795bc3154b8c.js +0 -1
  129. khoj/interface/compiled/_next/static/chunks/app/settings/page-cbe7f56b1f87d77a.js +0 -1
  130. khoj/interface/compiled/_next/static/chunks/app/share/chat/layout-592e8c470f2c2084.js +0 -1
  131. khoj/interface/compiled/_next/static/chunks/app/share/chat/page-cd5757199539bbf2.js +0 -1
  132. khoj/interface/compiled/_next/static/chunks/d3ac728e-a9e3522eef9b6b28.js +0 -1
  133. khoj/interface/compiled/_next/static/chunks/main-1ea5c2e0fdef4626.js +0 -1
  134. khoj/interface/compiled/_next/static/chunks/main-app-6d6ee3495efe03d4.js +0 -1
  135. khoj/interface/compiled/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js +0 -1
  136. khoj/interface/compiled/_next/static/chunks/webpack-616f0694bfe6f6c1.js +0 -1
  137. khoj/interface/compiled/_next/static/css/1f293605f2871853.css +0 -1
  138. khoj/interface/compiled/_next/static/css/3c34171b174cc381.css +0 -25
  139. khoj/interface/compiled/_next/static/css/3cf13271869a4aeb.css +0 -1
  140. khoj/interface/compiled/_next/static/css/592ca99f5122e75a.css +0 -1
  141. khoj/interface/compiled/_next/static/css/5a400c87d295e68a.css +0 -1
  142. khoj/interface/compiled/_next/static/css/80bd6301fc657983.css +0 -1
  143. khoj/interface/compiled/_next/static/css/9c4221ae0779cc04.css +0 -1
  144. /khoj/interface/compiled/_next/static/{67DcUiU9MqkM1fhksWunh → mqcIHpVqVWkmBuN0npYHA}/_ssgManifest.js +0 -0
  145. {khoj-1.30.11.dev64.dist-info → khoj-1.32.3.dev34.dist-info}/entry_points.txt +0 -0
  146. {khoj-1.30.11.dev64.dist-info → khoj-1.32.3.dev34.dist-info}/licenses/LICENSE +0 -0
@@ -102,8 +102,14 @@ async def search_online(
102
102
  async for event in send_status_func(f"**Searching the Internet for**: {subqueries_str}"):
103
103
  yield {ChatEvent.STATUS: event}
104
104
 
105
+ if SERPER_DEV_API_KEY:
106
+ search_func = search_with_serper
107
+ elif JINA_API_KEY:
108
+ search_func = search_with_jina
109
+ else:
110
+ search_func = search_with_searxng
111
+
105
112
  with timer(f"Internet searches for {subqueries} took", logger):
106
- search_func = search_with_google if SERPER_DEV_API_KEY else search_with_jina
107
113
  search_tasks = [search_func(subquery, location) for subquery in subqueries]
108
114
  search_results = await asyncio.gather(*search_tasks)
109
115
  response_dict = {subquery: search_result for subquery, search_result in search_results}
@@ -148,7 +154,48 @@ async def search_online(
148
154
  yield response_dict
149
155
 
150
156
 
151
- async def search_with_google(query: str, location: LocationData) -> Tuple[str, Dict[str, List[Dict]]]:
157
+ async def search_with_searxng(query: str, location: LocationData) -> Tuple[str, Dict[str, List[Dict]]]:
158
+ """Search using local SearXNG instance."""
159
+ # Use environment variable or default to localhost
160
+ searxng_url = os.getenv("KHOJ_SEARXNG_URL", "http://localhost:42113")
161
+ search_url = f"{searxng_url}/search"
162
+ country_code = location.country_code.lower() if location and location.country_code else "us"
163
+
164
+ params = {"q": query, "format": "html", "language": "en", "country": country_code, "categories": "general"}
165
+
166
+ async with aiohttp.ClientSession() as session:
167
+ try:
168
+ async with session.get(search_url, params=params) as response:
169
+ if response.status != 200:
170
+ logger.error(f"SearXNG search failed to call {searxng_url}: {await response.text()}")
171
+ return query, {}
172
+
173
+ html_content = await response.text()
174
+
175
+ soup = BeautifulSoup(html_content, "html.parser")
176
+ organic_results = []
177
+
178
+ for result in soup.find_all("article", class_="result"):
179
+ title_elem = result.find("a", rel="noreferrer")
180
+ if title_elem:
181
+ title = title_elem.text.strip()
182
+ link = title_elem["href"]
183
+
184
+ description_elem = result.find("p", class_="content")
185
+ description = description_elem.text.strip() if description_elem else None
186
+
187
+ organic_results.append({"title": title, "link": link, "description": description})
188
+
189
+ extracted_search_result = {"organic": organic_results}
190
+
191
+ return query, extracted_search_result
192
+
193
+ except Exception as e:
194
+ logger.error(f"Error searching with SearXNG: {str(e)}")
195
+ return query, {}
196
+
197
+
198
+ async def search_with_serper(query: str, location: LocationData) -> Tuple[str, Dict[str, List[Dict]]]:
152
199
  country_code = location.country_code.lower() if location and location.country_code else "us"
153
200
  payload = json.dumps({"q": query, "gl": country_code})
154
201
  headers = {"X-API-KEY": SERPER_DEV_API_KEY, "Content-Type": "application/json"}
khoj/routers/api.py CHANGED
@@ -28,12 +28,7 @@ from khoj.database.adapters import (
28
28
  get_default_search_model,
29
29
  get_user_photo,
30
30
  )
31
- from khoj.database.models import (
32
- Agent,
33
- ChatModelOptions,
34
- KhojUser,
35
- SpeechToTextModelOptions,
36
- )
31
+ from khoj.database.models import Agent, ChatModel, KhojUser, SpeechToTextModelOptions
37
32
  from khoj.processor.conversation import prompts
38
33
  from khoj.processor.conversation.anthropic.anthropic_chat import (
39
34
  extract_questions_anthropic,
@@ -404,15 +399,15 @@ async def extract_references_and_questions(
404
399
  # Infer search queries from user message
405
400
  with timer("Extracting search queries took", logger):
406
401
  # If we've reached here, either the user has enabled offline chat or the openai model is enabled.
407
- conversation_config = await ConversationAdapters.aget_default_conversation_config(user)
408
- vision_enabled = conversation_config.vision_enabled
402
+ chat_model = await ConversationAdapters.aget_default_chat_model(user)
403
+ vision_enabled = chat_model.vision_enabled
409
404
 
410
- if conversation_config.model_type == ChatModelOptions.ModelType.OFFLINE:
405
+ if chat_model.model_type == ChatModel.ModelType.OFFLINE:
411
406
  using_offline_chat = True
412
- chat_model = conversation_config.chat_model
413
- max_tokens = conversation_config.max_prompt_size
407
+ chat_model_name = chat_model.name
408
+ max_tokens = chat_model.max_prompt_size
414
409
  if state.offline_chat_processor_config is None:
415
- state.offline_chat_processor_config = OfflineChatProcessorModel(chat_model, max_tokens)
410
+ state.offline_chat_processor_config = OfflineChatProcessorModel(chat_model_name, max_tokens)
416
411
 
417
412
  loaded_model = state.offline_chat_processor_config.loaded_model
418
413
 
@@ -424,18 +419,18 @@ async def extract_references_and_questions(
424
419
  should_extract_questions=True,
425
420
  location_data=location_data,
426
421
  user=user,
427
- max_prompt_size=conversation_config.max_prompt_size,
422
+ max_prompt_size=chat_model.max_prompt_size,
428
423
  personality_context=personality_context,
429
424
  query_files=query_files,
430
425
  tracer=tracer,
431
426
  )
432
- elif conversation_config.model_type == ChatModelOptions.ModelType.OPENAI:
433
- api_key = conversation_config.ai_model_api.api_key
434
- base_url = conversation_config.ai_model_api.api_base_url
435
- chat_model = conversation_config.chat_model
427
+ elif chat_model.model_type == ChatModel.ModelType.OPENAI:
428
+ api_key = chat_model.ai_model_api.api_key
429
+ base_url = chat_model.ai_model_api.api_base_url
430
+ chat_model_name = chat_model.name
436
431
  inferred_queries = extract_questions(
437
432
  defiltered_query,
438
- model=chat_model,
433
+ model=chat_model_name,
439
434
  api_key=api_key,
440
435
  api_base_url=base_url,
441
436
  conversation_log=meta_log,
@@ -447,13 +442,13 @@ async def extract_references_and_questions(
447
442
  query_files=query_files,
448
443
  tracer=tracer,
449
444
  )
450
- elif conversation_config.model_type == ChatModelOptions.ModelType.ANTHROPIC:
451
- api_key = conversation_config.ai_model_api.api_key
452
- chat_model = conversation_config.chat_model
445
+ elif chat_model.model_type == ChatModel.ModelType.ANTHROPIC:
446
+ api_key = chat_model.ai_model_api.api_key
447
+ chat_model_name = chat_model.name
453
448
  inferred_queries = extract_questions_anthropic(
454
449
  defiltered_query,
455
450
  query_images=query_images,
456
- model=chat_model,
451
+ model=chat_model_name,
457
452
  api_key=api_key,
458
453
  conversation_log=meta_log,
459
454
  location_data=location_data,
@@ -463,17 +458,17 @@ async def extract_references_and_questions(
463
458
  query_files=query_files,
464
459
  tracer=tracer,
465
460
  )
466
- elif conversation_config.model_type == ChatModelOptions.ModelType.GOOGLE:
467
- api_key = conversation_config.ai_model_api.api_key
468
- chat_model = conversation_config.chat_model
461
+ elif chat_model.model_type == ChatModel.ModelType.GOOGLE:
462
+ api_key = chat_model.ai_model_api.api_key
463
+ chat_model_name = chat_model.name
469
464
  inferred_queries = extract_questions_gemini(
470
465
  defiltered_query,
471
466
  query_images=query_images,
472
- model=chat_model,
467
+ model=chat_model_name,
473
468
  api_key=api_key,
474
469
  conversation_log=meta_log,
475
470
  location_data=location_data,
476
- max_tokens=conversation_config.max_prompt_size,
471
+ max_tokens=chat_model.max_prompt_size,
477
472
  user=user,
478
473
  vision_enabled=vision_enabled,
479
474
  personality_context=personality_context,
@@ -62,7 +62,7 @@ async def all_agents(
62
62
  "color": agent.style_color,
63
63
  "icon": agent.style_icon,
64
64
  "privacy_level": agent.privacy_level,
65
- "chat_model": agent.chat_model.chat_model,
65
+ "chat_model": agent.chat_model.name,
66
66
  "files": file_names,
67
67
  "input_tools": agent.input_tools,
68
68
  "output_modes": agent.output_modes,
@@ -150,7 +150,7 @@ async def get_agent(
150
150
  "color": agent.style_color,
151
151
  "icon": agent.style_icon,
152
152
  "privacy_level": agent.privacy_level,
153
- "chat_model": agent.chat_model.chat_model,
153
+ "chat_model": agent.chat_model.name,
154
154
  "files": file_names,
155
155
  "input_tools": agent.input_tools,
156
156
  "output_modes": agent.output_modes,
@@ -225,7 +225,7 @@ async def create_agent(
225
225
  "color": agent.style_color,
226
226
  "icon": agent.style_icon,
227
227
  "privacy_level": agent.privacy_level,
228
- "chat_model": agent.chat_model.chat_model,
228
+ "chat_model": agent.chat_model.name,
229
229
  "files": body.files,
230
230
  "input_tools": agent.input_tools,
231
231
  "output_modes": agent.output_modes,
@@ -286,7 +286,7 @@ async def update_agent(
286
286
  "color": agent.style_color,
287
287
  "icon": agent.style_icon,
288
288
  "privacy_level": agent.privacy_level,
289
- "chat_model": agent.chat_model.chat_model,
289
+ "chat_model": agent.chat_model.name,
290
290
  "files": body.files,
291
291
  "input_tools": agent.input_tools,
292
292
  "output_modes": agent.output_modes,
khoj/routers/api_chat.py CHANGED
@@ -23,6 +23,7 @@ from khoj.database.adapters import (
23
23
  aget_user_name,
24
24
  )
25
25
  from khoj.database.models import Agent, KhojUser
26
+ from khoj.processor.conversation import prompts
26
27
  from khoj.processor.conversation.prompts import help_message, no_entries_found
27
28
  from khoj.processor.conversation.utils import defilter_query, save_to_conversation_log
28
29
  from khoj.processor.image.generate import text_to_image
@@ -57,7 +58,7 @@ from khoj.routers.helpers import (
57
58
  is_ready_to_chat,
58
59
  read_chat_stream,
59
60
  update_telemetry_state,
60
- validate_conversation_config,
61
+ validate_chat_model,
61
62
  )
62
63
  from khoj.routers.research import (
63
64
  InformationCollectionIteration,
@@ -204,7 +205,7 @@ def chat_history(
204
205
  n: Optional[int] = None,
205
206
  ):
206
207
  user = request.user.object
207
- validate_conversation_config(user)
208
+ validate_chat_model(user)
208
209
 
209
210
  # Load Conversation History
210
211
  conversation = ConversationAdapters.get_conversation_by_user(
@@ -723,7 +724,16 @@ async def chat(
723
724
  yield result
724
725
  return
725
726
 
726
- conversation_commands = [get_conversation_command(query=q, any_references=True)]
727
+ # Automated tasks are handled before to allow mixing them with other conversation commands
728
+ cmds_to_rate_limit = []
729
+ is_automated_task = False
730
+ if q.startswith("/automated_task"):
731
+ is_automated_task = True
732
+ q = q.replace("/automated_task", "").lstrip()
733
+ cmds_to_rate_limit += [ConversationCommand.AutomatedTask]
734
+
735
+ # Extract conversation command from query
736
+ conversation_commands = [get_conversation_command(query=q)]
727
737
 
728
738
  conversation = await ConversationAdapters.aget_conversation_by_user(
729
739
  user,
@@ -756,15 +766,13 @@ async def chat(
756
766
  location = None
757
767
  if city or region or country or country_code:
758
768
  location = LocationData(city=city, region=region, country=country, country_code=country_code)
759
-
760
769
  user_message_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
761
-
762
770
  meta_log = conversation.conversation_log
763
- is_automated_task = conversation_commands == [ConversationCommand.AutomatedTask]
764
771
 
765
772
  researched_results = ""
766
773
  online_results: Dict = dict()
767
774
  code_results: Dict = dict()
775
+ generated_asset_results: Dict = dict()
768
776
  ## Extract Document References
769
777
  compiled_references: List[Any] = []
770
778
  inferred_queries: List[Any] = []
@@ -776,7 +784,7 @@ async def chat(
776
784
  generated_excalidraw_diagram: str = None
777
785
  program_execution_context: List[str] = []
778
786
 
779
- if conversation_commands == [ConversationCommand.Default] or is_automated_task:
787
+ if conversation_commands == [ConversationCommand.Default]:
780
788
  chosen_io = await aget_data_sources_and_output_format(
781
789
  q,
782
790
  meta_log,
@@ -797,7 +805,8 @@ async def chat(
797
805
  async for result in send_event(ChatEvent.STATUS, f"**Selected Tools:** {conversation_commands_str}"):
798
806
  yield result
799
807
 
800
- for cmd in conversation_commands:
808
+ cmds_to_rate_limit += conversation_commands
809
+ for cmd in cmds_to_rate_limit:
801
810
  try:
802
811
  await conversation_command_rate_limiter.update_and_check_if_valid(request, cmd)
803
812
  q = q.replace(f"/{cmd.value}", "").strip()
@@ -896,10 +905,10 @@ async def chat(
896
905
  custom_filters = []
897
906
  if conversation_commands == [ConversationCommand.Help]:
898
907
  if not q:
899
- conversation_config = await ConversationAdapters.aget_user_conversation_config(user)
900
- if conversation_config == None:
901
- conversation_config = await ConversationAdapters.aget_default_conversation_config(user)
902
- model_type = conversation_config.model_type
908
+ chat_model = await ConversationAdapters.aget_user_chat_model(user)
909
+ if chat_model == None:
910
+ chat_model = await ConversationAdapters.aget_default_chat_model(user)
911
+ model_type = chat_model.model_type
903
912
  formatted_help = help_message.format(model=model_type, version=state.khoj_version, device=get_device())
904
913
  async for result in send_llm_response(formatted_help, tracer.get("usage")):
905
914
  yield result
@@ -1128,6 +1137,10 @@ async def chat(
1128
1137
  else:
1129
1138
  generated_images.append(generated_image)
1130
1139
 
1140
+ generated_asset_results["images"] = {
1141
+ "query": improved_image_prompt,
1142
+ }
1143
+
1131
1144
  async for result in send_event(
1132
1145
  ChatEvent.GENERATED_ASSETS,
1133
1146
  {
@@ -1166,6 +1179,10 @@ async def chat(
1166
1179
 
1167
1180
  generated_excalidraw_diagram = diagram_description
1168
1181
 
1182
+ generated_asset_results["diagrams"] = {
1183
+ "query": better_diagram_description_prompt,
1184
+ }
1185
+
1169
1186
  async for result in send_event(
1170
1187
  ChatEvent.GENERATED_ASSETS,
1171
1188
  {
@@ -1176,7 +1193,9 @@ async def chat(
1176
1193
  else:
1177
1194
  error_message = "Failed to generate diagram. Please try again later."
1178
1195
  program_execution_context.append(
1179
- f"AI attempted to programmatically generate a diagram but failed due to a program issue. Generally, it is able to do so, but encountered a system issue this time. AI can suggest text description or rendering of the diagram or user can try again with a simpler prompt."
1196
+ prompts.failed_diagram_generation.format(
1197
+ attempted_diagram=better_diagram_description_prompt
1198
+ )
1180
1199
  )
1181
1200
 
1182
1201
  async for result in send_event(ChatEvent.STATUS, error_message):
@@ -1209,6 +1228,7 @@ async def chat(
1209
1228
  generated_files,
1210
1229
  generated_excalidraw_diagram,
1211
1230
  program_execution_context,
1231
+ generated_asset_results,
1212
1232
  tracer,
1213
1233
  )
1214
1234
 
khoj/routers/api_model.py CHANGED
@@ -24,7 +24,7 @@ def get_chat_model_options(
24
24
 
25
25
  all_conversation_options = list()
26
26
  for conversation_option in conversation_options:
27
- all_conversation_options.append({"chat_model": conversation_option.chat_model, "id": conversation_option.id})
27
+ all_conversation_options.append({"chat_model": conversation_option.name, "id": conversation_option.id})
28
28
 
29
29
  return Response(content=json.dumps(all_conversation_options), media_type="application/json", status_code=200)
30
30
 
@@ -37,12 +37,12 @@ def get_user_chat_model(
37
37
  ):
38
38
  user = request.user.object
39
39
 
40
- chat_model = ConversationAdapters.get_conversation_config(user)
40
+ chat_model = ConversationAdapters.get_chat_model(user)
41
41
 
42
42
  if chat_model is None:
43
- chat_model = ConversationAdapters.get_default_conversation_config(user)
43
+ chat_model = ConversationAdapters.get_default_chat_model(user)
44
44
 
45
- return Response(status_code=200, content=json.dumps({"id": chat_model.id, "chat_model": chat_model.chat_model}))
45
+ return Response(status_code=200, content=json.dumps({"id": chat_model.id, "chat_model": chat_model.name}))
46
46
 
47
47
 
48
48
  @api_model.post("/chat", status_code=200)
khoj/routers/auth.py CHANGED
@@ -4,7 +4,8 @@ import logging
4
4
  import os
5
5
  from typing import Optional
6
6
 
7
- from fastapi import APIRouter
7
+ import requests
8
+ from fastapi import APIRouter, Depends
8
9
  from pydantic import BaseModel, EmailStr
9
10
  from starlette.authentication import requires
10
11
  from starlette.config import Config
@@ -21,8 +22,13 @@ from khoj.database.adapters import (
21
22
  get_or_create_user,
22
23
  )
23
24
  from khoj.routers.email import send_magic_link_email, send_welcome_email
24
- from khoj.routers.helpers import get_next_url, update_telemetry_state
25
+ from khoj.routers.helpers import (
26
+ EmailVerificationApiRateLimiter,
27
+ get_next_url,
28
+ update_telemetry_state,
29
+ )
25
30
  from khoj.utils import state
31
+ from khoj.utils.helpers import in_debug_mode
26
32
 
27
33
  logger = logging.getLogger(__name__)
28
34
 
@@ -98,16 +104,28 @@ async def login_magic_link(request: Request, form: MagicLinkForm):
98
104
 
99
105
 
100
106
  @auth_router.get("/magic")
101
- async def sign_in_with_magic_link(request: Request, code: str):
102
- user = await aget_user_validated_by_email_verification_code(code)
107
+ async def sign_in_with_magic_link(
108
+ request: Request,
109
+ code: str,
110
+ email: str,
111
+ rate_limiter=Depends(
112
+ EmailVerificationApiRateLimiter(requests=10, window=60 * 60 * 24, slug="magic_link_verification")
113
+ ),
114
+ ):
115
+ user, code_is_expired = await aget_user_validated_by_email_verification_code(code, email)
116
+
103
117
  if user:
118
+ if code_is_expired:
119
+ request.session["user"] = {}
120
+ return Response(status_code=403)
121
+
104
122
  id_info = {
105
123
  "email": user.email,
106
124
  }
107
125
 
108
126
  request.session["user"] = dict(id_info)
109
127
  return RedirectResponse(url="/")
110
- return RedirectResponse(request.app.url_path_for("login_page"))
128
+ return Response(status_code=401)
111
129
 
112
130
 
113
131
  @auth_router.post("/token")
@@ -140,11 +158,12 @@ async def delete_token(request: Request, token: str):
140
158
 
141
159
 
142
160
  @auth_router.post("/redirect")
143
- async def auth(request: Request):
161
+ async def auth_post(request: Request):
162
+ # This is maintained for compatibility with the /login endpoint
144
163
  form = await request.form()
145
164
  next_url = get_next_url(request)
146
165
  for q in request.query_params:
147
- if not q == "next":
166
+ if q != "next":
148
167
  next_url += f"&{q}={request.query_params[q]}"
149
168
 
150
169
  credential = form.get("credential")
@@ -183,7 +202,89 @@ async def auth(request: Request):
183
202
  return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)
184
203
 
185
204
 
205
+ @auth_router.get("/redirect")
206
+ async def auth(request: Request):
207
+ next_url = get_next_url(request)
208
+ for q in request.query_params:
209
+ if q in ["code", "state", "scope", "authuser", "prompt", "session_state", "access_type"]:
210
+ continue
211
+ if q != "next":
212
+ next_url += f"&{q}={request.query_params[q]}"
213
+
214
+ code = request.query_params.get("code")
215
+
216
+ # 1. Construct the full redirect URI including domain
217
+ base_url = str(request.base_url).rstrip("/")
218
+
219
+ if not in_debug_mode():
220
+ base_url = base_url.replace("http://", "https://")
221
+
222
+ redirect_uri = f"{base_url}{request.app.url_path_for('auth')}"
223
+
224
+ payload = {
225
+ "code": code,
226
+ "client_id": os.environ["GOOGLE_CLIENT_ID"],
227
+ "client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
228
+ "redirect_uri": redirect_uri,
229
+ "grant_type": "authorization_code",
230
+ }
231
+
232
+ verified_data = requests.post(
233
+ "https://oauth2.googleapis.com/token",
234
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
235
+ data=payload,
236
+ )
237
+
238
+ if verified_data.status_code != 200:
239
+ logger.error(f"Token request failed: {verified_data.text}")
240
+ try:
241
+ error_json = verified_data.json()
242
+ logger.error(f"Error response JSON for Google verification: {error_json}")
243
+ except ValueError:
244
+ logger.error("Response content is not valid JSON")
245
+ verified_data.raise_for_status()
246
+
247
+ credential = verified_data.json().get("id_token")
248
+
249
+ if not credential:
250
+ logger.error("Missing id_token in OAuth response")
251
+ return RedirectResponse(url="/login?error=invalid_token", status_code=HTTP_302_FOUND)
252
+
253
+ try:
254
+ idinfo = id_token.verify_oauth2_token(credential, google_requests.Request(), os.environ["GOOGLE_CLIENT_ID"])
255
+ except OAuthError as error:
256
+ return HTMLResponse(f"<h1>{error.error}</h1>")
257
+ khoj_user = await get_or_create_user(idinfo)
258
+
259
+ if khoj_user:
260
+ request.session["user"] = dict(idinfo)
261
+
262
+ if datetime.timedelta(minutes=3) > (datetime.datetime.now(datetime.timezone.utc) - khoj_user.date_joined):
263
+ asyncio.create_task(send_welcome_email(idinfo["name"], idinfo["email"]))
264
+ update_telemetry_state(
265
+ request=request,
266
+ telemetry_type="api",
267
+ api="create_user__google",
268
+ metadata={"server_id": str(khoj_user.uuid)},
269
+ )
270
+ logger.log(logging.INFO, f"🥳 New User Created: {khoj_user.uuid}")
271
+
272
+ return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)
273
+
274
+
186
275
  @auth_router.get("/logout")
187
276
  async def logout(request: Request):
188
277
  request.session.pop("user", None)
189
278
  return RedirectResponse(url="/")
279
+
280
+
281
+ @auth_router.get("/oauth/metadata")
282
+ async def oauth_metadata(request: Request):
283
+ redirect_uri = str(request.app.url_path_for("auth"))
284
+
285
+ return {
286
+ "google": {
287
+ "client_id": os.environ.get("GOOGLE_CLIENT_ID"),
288
+ "redirect_uri": f"{redirect_uri}",
289
+ }
290
+ }
khoj/routers/email.py CHANGED
@@ -1,12 +1,8 @@
1
1
  import logging
2
2
  import os
3
3
 
4
- try:
5
- import resend
6
- except ImportError:
7
- pass
8
-
9
4
  import markdown_it
5
+ import resend
10
6
  from django.conf import settings
11
7
  from jinja2 import Environment, FileSystemLoader
12
8
 
@@ -23,7 +19,7 @@ static_files = os.path.join(settings.BASE_DIR, "static")
23
19
  env = Environment(loader=FileSystemLoader(static_files))
24
20
 
25
21
  if not RESEND_API_KEY:
26
- logger.warn("RESEND_API_KEY not set - email sending disabled")
22
+ logger.warning("RESEND_API_KEY not set - email sending disabled")
27
23
  else:
28
24
  resend.api_key = RESEND_API_KEY
29
25
 
@@ -33,7 +29,7 @@ def is_resend_enabled():
33
29
 
34
30
 
35
31
  async def send_magic_link_email(email, unique_id, host):
36
- sign_in_link = f"{host}auth/magic?code={unique_id}"
32
+ sign_in_link = f"{host}auth/magic?code={unique_id}&email={email}"
37
33
 
38
34
  if not is_resend_enabled():
39
35
  logger.debug(f"Email sending disabled. Share this sign-in link with the user: {sign_in_link}")
@@ -41,13 +37,13 @@ async def send_magic_link_email(email, unique_id, host):
41
37
 
42
38
  template = env.get_template("magic_link.html")
43
39
 
44
- html_content = template.render(link=f"{host}auth/magic?code={unique_id}")
40
+ html_content = template.render(link=f"{host}auth/magic?code={unique_id}", code=unique_id)
45
41
 
46
42
  resend.Emails.send(
47
43
  {
48
44
  "sender": os.environ.get("RESEND_EMAIL", "noreply@khoj.dev"),
49
45
  "to": email,
50
- "subject": "Your Sign-In Link for Khoj 🚀",
46
+ "subject": f"Your login code to Khoj",
51
47
  "html": html_content,
52
48
  }
53
49
  )
@@ -64,7 +60,7 @@ async def send_welcome_email(name, email):
64
60
 
65
61
  resend.Emails.send(
66
62
  {
67
- "sender": "team@khoj.dev",
63
+ "sender": os.environ.get("RESEND_EMAIL", "team@khoj.dev"),
68
64
  "to": email,
69
65
  "subject": f"{name}, four ways to use Khoj" if name else "Four ways to use Khoj",
70
66
  "html": html_content,
@@ -92,7 +88,7 @@ async def send_query_feedback(uquery, kquery, sentiment, user_email):
92
88
 
93
89
  logger.info(f"Sending feedback email for query {uquery}")
94
90
 
95
- # rendering feedback email using feedback.html as template
91
+ # render feedback email using feedback.html as template
96
92
  template = env.get_template("feedback.html")
97
93
  html_content = template.render(
98
94
  uquery=uquery if not is_none_or_empty(uquery) else "N/A",
@@ -100,10 +96,10 @@ async def send_query_feedback(uquery, kquery, sentiment, user_email):
100
96
  sentiment=sentiment if not is_none_or_empty(sentiment) else "N/A",
101
97
  user_email=user_email if not is_none_or_empty(user_email) else "N/A",
102
98
  )
103
- # send feedback from two fixed accounts
99
+ # send feedback to fixed account
104
100
  r = resend.Emails.send(
105
101
  {
106
- "sender": "saba@khoj.dev",
102
+ "sender": os.environ.get("RESEND_EMAIL", "noreply@khoj.dev"),
107
103
  "to": "team@khoj.dev",
108
104
  "subject": f"User Feedback",
109
105
  "html": html_content,
@@ -130,7 +126,7 @@ def send_task_email(name, email, query, result, subject, is_image=False):
130
126
 
131
127
  r = resend.Emails.send(
132
128
  {
133
- "sender": "Khoj <khoj@khoj.dev>",
129
+ "sender": f'Khoj <{os.environ.get("RESEND_EMAIL", "khoj@khoj.dev")}>',
134
130
  "to": email,
135
131
  "subject": f"✨ {subject}",
136
132
  "html": html_content,