khoj 1.31.1.dev13__py3-none-any.whl → 1.32.0__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 (121) hide show
  1. khoj/configure.py +4 -2
  2. khoj/database/adapters/__init__.py +15 -6
  3. khoj/database/admin.py +1 -1
  4. khoj/database/migrations/0078_khojuser_email_verification_code_expiry.py +17 -0
  5. khoj/database/models/__init__.py +1 -0
  6. khoj/interface/compiled/404/index.html +1 -1
  7. khoj/interface/compiled/_next/static/EXN7llilwwtkW67UhumHd/_buildManifest.js +1 -0
  8. khoj/interface/compiled/_next/static/chunks/1201-aac5b5f9a28edf09.js +1 -0
  9. khoj/interface/compiled/_next/static/chunks/1662-adf4c615bef2fdc2.js +1 -0
  10. khoj/interface/compiled/_next/static/chunks/1915-878efdc6db697d8f.js +1 -0
  11. khoj/interface/compiled/_next/static/chunks/2117-9886e6a0232dc093.js +2 -0
  12. khoj/interface/compiled/_next/static/chunks/{5538-0ea2d3944ca051e1.js → 2264-23b2c33cd8c74d07.js} +1 -1
  13. khoj/interface/compiled/_next/static/chunks/2781-4f022b6e9eb6df6e.js +3 -0
  14. khoj/interface/compiled/_next/static/chunks/2813-f842b08bce4c61a0.js +1 -0
  15. khoj/interface/compiled/_next/static/chunks/3091-e0ff2288e8a29dd7.js +1 -0
  16. khoj/interface/compiled/_next/static/chunks/3727.dcea8f2193111552.js +1 -0
  17. khoj/interface/compiled/_next/static/chunks/5401-980a4f512c81232e.js +20 -0
  18. khoj/interface/compiled/_next/static/chunks/{1279-4cb23143aa2c0228.js → 5473-b1cf56dedac6577a.js} +1 -1
  19. khoj/interface/compiled/_next/static/chunks/5477-8d032883aed8a2d2.js +1 -0
  20. khoj/interface/compiled/_next/static/chunks/6589-f806113de469d684.js +1 -0
  21. khoj/interface/compiled/_next/static/chunks/8117-2e1697b782c5f185.js +1 -0
  22. khoj/interface/compiled/_next/static/chunks/8407-af326f8c200e619b.js +1 -0
  23. khoj/interface/compiled/_next/static/chunks/8667-d3e5bc726e4ff4e3.js +1 -0
  24. khoj/interface/compiled/_next/static/chunks/9058-25ef3344805f06ea.js +1 -0
  25. khoj/interface/compiled/_next/static/chunks/9262-21c17de77aafdce8.js +1 -0
  26. khoj/interface/compiled/_next/static/chunks/94ca1967.1d9b42d929a1ee8c.js +1 -0
  27. khoj/interface/compiled/_next/static/chunks/{1210.ef7a0f9a7e43da1d.js → 9597.83583248dfbf6e73.js} +1 -1
  28. khoj/interface/compiled/_next/static/chunks/964ecbae.51d6faf8801d15e6.js +1 -0
  29. khoj/interface/compiled/_next/static/chunks/app/_not-found/{page-cfba071f5a657256.js → page-a834eddae3e235df.js} +1 -1
  30. khoj/interface/compiled/_next/static/chunks/app/agents/layout-e00fb81dca656a10.js +1 -0
  31. khoj/interface/compiled/_next/static/chunks/app/agents/page-6f4ff1d32a66ed71.js +1 -0
  32. khoj/interface/compiled/_next/static/chunks/app/automations/{layout-7f1b79a2c67af0b4.js → layout-dce809da279a4a8a.js} +1 -1
  33. khoj/interface/compiled/_next/static/chunks/app/automations/page-148a48ddfb2ff90d.js +1 -0
  34. khoj/interface/compiled/_next/static/chunks/app/chat/layout-33934fc2d6ae6838.js +1 -0
  35. khoj/interface/compiled/_next/static/chunks/app/chat/page-be00870a40de3a25.js +1 -0
  36. khoj/interface/compiled/_next/static/chunks/app/layout-30e7fda7262713ce.js +1 -0
  37. khoj/interface/compiled/_next/static/chunks/app/page-765292332c31523e.js +1 -0
  38. khoj/interface/compiled/_next/static/chunks/app/search/layout-c02531d586972d7d.js +1 -0
  39. khoj/interface/compiled/_next/static/chunks/app/search/{page-845fe099f1f4375e.js → page-7af2cab294dccd81.js} +1 -1
  40. khoj/interface/compiled/_next/static/chunks/app/settings/layout-b3f6bc6f1aa118e0.js +1 -0
  41. khoj/interface/compiled/_next/static/chunks/app/settings/page-6b600bf11fa89194.js +1 -0
  42. khoj/interface/compiled/_next/static/chunks/app/share/chat/layout-e8e5db7830bf3f47.js +1 -0
  43. khoj/interface/compiled/_next/static/chunks/app/share/chat/{page-f625859c1a122441.js → page-6054e88b56708f44.js} +1 -1
  44. khoj/interface/compiled/_next/static/chunks/d3ac728e-44ebd2a0c99b12a0.js +1 -0
  45. khoj/interface/compiled/_next/static/chunks/{fd9d1056-2e6c8140e79afc3b.js → fd9d1056-4482b99a36fd1673.js} +1 -1
  46. khoj/interface/compiled/_next/static/chunks/main-app-de1f09df97a3cfc7.js +1 -0
  47. khoj/interface/compiled/_next/static/chunks/main-db4bfac6b0a8d00b.js +1 -0
  48. khoj/interface/compiled/_next/static/chunks/pages/{_app-f870474a17b7f2fd.js → _app-3c9ca398d360b709.js} +1 -1
  49. khoj/interface/compiled/_next/static/chunks/pages/{_error-c66a4e8afc46f17b.js → _error-cf5ca766ac8f493f.js} +1 -1
  50. khoj/interface/compiled/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  51. khoj/interface/compiled/_next/static/chunks/webpack-fe0d01dd1cc44c15.js +1 -0
  52. khoj/interface/compiled/_next/static/css/3f27c3cf45375eb5.css +1 -0
  53. khoj/interface/compiled/_next/static/css/65ac59e147eb2057.css +25 -0
  54. khoj/interface/compiled/_next/static/css/9504108437df6804.css +1 -0
  55. khoj/interface/compiled/_next/static/css/c8837bdb7d5f13de.css +1 -0
  56. khoj/interface/compiled/agents/index.html +1 -1
  57. khoj/interface/compiled/agents/index.txt +6 -6
  58. khoj/interface/compiled/automations/index.html +1 -1
  59. khoj/interface/compiled/automations/index.txt +7 -7
  60. khoj/interface/compiled/chat/index.html +1 -1
  61. khoj/interface/compiled/chat/index.txt +6 -6
  62. khoj/interface/compiled/index.html +1 -1
  63. khoj/interface/compiled/index.txt +6 -6
  64. khoj/interface/compiled/search/index.html +1 -1
  65. khoj/interface/compiled/search/index.txt +6 -6
  66. khoj/interface/compiled/settings/index.html +1 -1
  67. khoj/interface/compiled/settings/index.txt +8 -8
  68. khoj/interface/compiled/share/chat/index.html +1 -1
  69. khoj/interface/compiled/share/chat/index.txt +6 -6
  70. khoj/interface/email/magic_link.html +36 -13
  71. khoj/main.py +1 -1
  72. khoj/processor/tools/online_search.py +49 -2
  73. khoj/routers/api_chat.py +13 -6
  74. khoj/routers/auth.py +94 -7
  75. khoj/routers/email.py +10 -14
  76. khoj/routers/helpers.py +70 -32
  77. khoj/routers/web_client.py +1 -1
  78. {khoj-1.31.1.dev13.dist-info → khoj-1.32.0.dist-info}/METADATA +5 -5
  79. {khoj-1.31.1.dev13.dist-info → khoj-1.32.0.dist-info}/RECORD +83 -81
  80. {khoj-1.31.1.dev13.dist-info → khoj-1.32.0.dist-info}/WHEEL +1 -1
  81. khoj/interface/compiled/_next/static/7K5OLB23CafMhZBFHh4NG/_buildManifest.js +0 -1
  82. khoj/interface/compiled/_next/static/chunks/1459.690bf20e7d7b7090.js +0 -1
  83. khoj/interface/compiled/_next/static/chunks/1603-f8ef9930c1f4eaef.js +0 -1
  84. khoj/interface/compiled/_next/static/chunks/1970-1b63ac1497b03a10.js +0 -1
  85. khoj/interface/compiled/_next/static/chunks/2646-92ba433951d02d52.js +0 -20
  86. khoj/interface/compiled/_next/static/chunks/3072-be830e4f8412b9d2.js +0 -1
  87. khoj/interface/compiled/_next/static/chunks/3463-081c031e873b7966.js +0 -3
  88. khoj/interface/compiled/_next/static/chunks/3690-51312931ba1eae30.js +0 -1
  89. khoj/interface/compiled/_next/static/chunks/3717-b46079dbe9f55694.js +0 -1
  90. khoj/interface/compiled/_next/static/chunks/4200-ea75740bb3c6ae60.js +0 -1
  91. khoj/interface/compiled/_next/static/chunks/4504-62ac13e7d94c52f9.js +0 -1
  92. khoj/interface/compiled/_next/static/chunks/4602-460621c3241e0d13.js +0 -1
  93. khoj/interface/compiled/_next/static/chunks/5512-7cc62049bbe60e11.js +0 -1
  94. khoj/interface/compiled/_next/static/chunks/7023-e8de2bded4df6539.js +0 -2
  95. khoj/interface/compiled/_next/static/chunks/7592-a09c39a38e60634b.js +0 -1
  96. khoj/interface/compiled/_next/static/chunks/8423-1dda16bc56236523.js +0 -1
  97. khoj/interface/compiled/_next/static/chunks/94ca1967.5584df65931cfe83.js +0 -1
  98. khoj/interface/compiled/_next/static/chunks/964ecbae.ea4eab2a3a835ffe.js +0 -1
  99. khoj/interface/compiled/_next/static/chunks/app/agents/layout-1878cc328ea380bd.js +0 -1
  100. khoj/interface/compiled/_next/static/chunks/app/agents/page-2a0b821cf69bdf06.js +0 -1
  101. khoj/interface/compiled/_next/static/chunks/app/automations/page-ffa30be1dda97643.js +0 -1
  102. khoj/interface/compiled/_next/static/chunks/app/chat/layout-9219a85f3477e722.js +0 -1
  103. khoj/interface/compiled/_next/static/chunks/app/chat/page-c2c62ae6f013443c.js +0 -1
  104. khoj/interface/compiled/_next/static/chunks/app/layout-6310c57b674dd6f5.js +0 -1
  105. khoj/interface/compiled/_next/static/chunks/app/page-083f798a7562cda5.js +0 -1
  106. khoj/interface/compiled/_next/static/chunks/app/search/layout-2ca475462c0b2176.js +0 -1
  107. khoj/interface/compiled/_next/static/chunks/app/settings/layout-f285795bc3154b8c.js +0 -1
  108. khoj/interface/compiled/_next/static/chunks/app/settings/page-3257ef0146ab18da.js +0 -1
  109. khoj/interface/compiled/_next/static/chunks/app/share/chat/layout-592e8c470f2c2084.js +0 -1
  110. khoj/interface/compiled/_next/static/chunks/d3ac728e-a9e3522eef9b6b28.js +0 -1
  111. khoj/interface/compiled/_next/static/chunks/main-1ea5c2e0fdef4626.js +0 -1
  112. khoj/interface/compiled/_next/static/chunks/main-app-6d6ee3495efe03d4.js +0 -1
  113. khoj/interface/compiled/_next/static/chunks/polyfills-78c92fac7aa8fdd8.js +0 -1
  114. khoj/interface/compiled/_next/static/chunks/webpack-062298330010d2aa.js +0 -1
  115. khoj/interface/compiled/_next/static/css/1f293605f2871853.css +0 -1
  116. khoj/interface/compiled/_next/static/css/80bd6301fc657983.css +0 -1
  117. khoj/interface/compiled/_next/static/css/f172a0fb3eb177e1.css +0 -1
  118. khoj/interface/compiled/_next/static/css/fd628f01a581ec3c.css +0 -25
  119. /khoj/interface/compiled/_next/static/{7K5OLB23CafMhZBFHh4NG → EXN7llilwwtkW67UhumHd}/_ssgManifest.js +0 -0
  120. {khoj-1.31.1.dev13.dist-info → khoj-1.32.0.dist-info}/entry_points.txt +0 -0
  121. {khoj-1.31.1.dev13.dist-info → khoj-1.32.0.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_chat.py CHANGED
@@ -724,7 +724,16 @@ async def chat(
724
724
  yield result
725
725
  return
726
726
 
727
- 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)]
728
737
 
729
738
  conversation = await ConversationAdapters.aget_conversation_by_user(
730
739
  user,
@@ -757,11 +766,8 @@ async def chat(
757
766
  location = None
758
767
  if city or region or country or country_code:
759
768
  location = LocationData(city=city, region=region, country=country, country_code=country_code)
760
-
761
769
  user_message_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
762
-
763
770
  meta_log = conversation.conversation_log
764
- is_automated_task = conversation_commands == [ConversationCommand.AutomatedTask]
765
771
 
766
772
  researched_results = ""
767
773
  online_results: Dict = dict()
@@ -778,7 +784,7 @@ async def chat(
778
784
  generated_excalidraw_diagram: str = None
779
785
  program_execution_context: List[str] = []
780
786
 
781
- if conversation_commands == [ConversationCommand.Default] or is_automated_task:
787
+ if conversation_commands == [ConversationCommand.Default]:
782
788
  chosen_io = await aget_data_sources_and_output_format(
783
789
  q,
784
790
  meta_log,
@@ -799,7 +805,8 @@ async def chat(
799
805
  async for result in send_event(ChatEvent.STATUS, f"**Selected Tools:** {conversation_commands_str}"):
800
806
  yield result
801
807
 
802
- for cmd in conversation_commands:
808
+ cmds_to_rate_limit += conversation_commands
809
+ for cmd in cmds_to_rate_limit:
803
810
  try:
804
811
  await conversation_command_rate_limiter.update_and_check_if_valid(request, cmd)
805
812
  q = q.replace(f"/{cmd.value}", "").strip()
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,7 +22,11 @@ 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
26
31
 
27
32
  logger = logging.getLogger(__name__)
@@ -98,16 +103,28 @@ async def login_magic_link(request: Request, form: MagicLinkForm):
98
103
 
99
104
 
100
105
  @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)
106
+ async def sign_in_with_magic_link(
107
+ request: Request,
108
+ code: str,
109
+ email: str,
110
+ rate_limiter=Depends(
111
+ EmailVerificationApiRateLimiter(requests=10, window=60 * 60 * 24, slug="magic_link_verification")
112
+ ),
113
+ ):
114
+ user, code_is_expired = await aget_user_validated_by_email_verification_code(code, email)
115
+
103
116
  if user:
117
+ if code_is_expired:
118
+ request.session["user"] = {}
119
+ return Response(status_code=403)
120
+
104
121
  id_info = {
105
122
  "email": user.email,
106
123
  }
107
124
 
108
125
  request.session["user"] = dict(id_info)
109
126
  return RedirectResponse(url="/")
110
- return RedirectResponse(request.app.url_path_for("login_page"))
127
+ return Response(status_code=401)
111
128
 
112
129
 
113
130
  @auth_router.post("/token")
@@ -140,11 +157,12 @@ async def delete_token(request: Request, token: str):
140
157
 
141
158
 
142
159
  @auth_router.post("/redirect")
143
- async def auth(request: Request):
160
+ async def auth_post(request: Request):
161
+ # This is maintained for compatibility with the /login endpoint
144
162
  form = await request.form()
145
163
  next_url = get_next_url(request)
146
164
  for q in request.query_params:
147
- if not q == "next":
165
+ if q != "next":
148
166
  next_url += f"&{q}={request.query_params[q]}"
149
167
 
150
168
  credential = form.get("credential")
@@ -183,7 +201,76 @@ async def auth(request: Request):
183
201
  return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)
184
202
 
185
203
 
204
+ @auth_router.get("/redirect")
205
+ async def auth(request: Request):
206
+ next_url = get_next_url(request)
207
+ for q in request.query_params:
208
+ if q in ["code", "state", "scope", "authuser", "prompt", "session_state", "access_type"]:
209
+ continue
210
+ if q != "next":
211
+ next_url += f"&{q}={request.query_params[q]}"
212
+
213
+ code = request.query_params.get("code")
214
+
215
+ # 1. Construct the full redirect URI including domain
216
+ base_url = str(request.base_url).rstrip("/")
217
+ redirect_uri = f"{base_url}{request.app.url_path_for('auth')}"
218
+
219
+ verified_data = requests.post(
220
+ "https://oauth2.googleapis.com/token",
221
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
222
+ data={
223
+ "code": code,
224
+ "client_id": os.environ["GOOGLE_CLIENT_ID"],
225
+ "client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
226
+ "redirect_uri": redirect_uri,
227
+ "grant_type": "authorization_code",
228
+ },
229
+ )
230
+
231
+ verified_data.raise_for_status()
232
+
233
+ credential = verified_data.json().get("id_token")
234
+
235
+ if not credential:
236
+ logger.error("Missing id_token in OAuth response")
237
+ return RedirectResponse(url="/login?error=invalid_token", status_code=HTTP_302_FOUND)
238
+
239
+ try:
240
+ idinfo = id_token.verify_oauth2_token(credential, google_requests.Request(), os.environ["GOOGLE_CLIENT_ID"])
241
+ except OAuthError as error:
242
+ return HTMLResponse(f"<h1>{error.error}</h1>")
243
+ khoj_user = await get_or_create_user(idinfo)
244
+
245
+ if khoj_user:
246
+ request.session["user"] = dict(idinfo)
247
+
248
+ if datetime.timedelta(minutes=3) > (datetime.datetime.now(datetime.timezone.utc) - khoj_user.date_joined):
249
+ asyncio.create_task(send_welcome_email(idinfo["name"], idinfo["email"]))
250
+ update_telemetry_state(
251
+ request=request,
252
+ telemetry_type="api",
253
+ api="create_user__google",
254
+ metadata={"server_id": str(khoj_user.uuid)},
255
+ )
256
+ logger.log(logging.INFO, f"🥳 New User Created: {khoj_user.uuid}")
257
+
258
+ return RedirectResponse(url=next_url, status_code=HTTP_302_FOUND)
259
+
260
+
186
261
  @auth_router.get("/logout")
187
262
  async def logout(request: Request):
188
263
  request.session.pop("user", None)
189
264
  return RedirectResponse(url="/")
265
+
266
+
267
+ @auth_router.get("/oauth/metadata")
268
+ async def oauth_metadata(request: Request):
269
+ redirect_uri = str(request.app.url_path_for("auth"))
270
+
271
+ return {
272
+ "google": {
273
+ "client_id": os.environ.get("GOOGLE_CLIENT_ID"),
274
+ "redirect_uri": f"{redirect_uri}",
275
+ }
276
+ }
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,
khoj/routers/helpers.py CHANGED
@@ -49,6 +49,7 @@ from khoj.database.adapters import (
49
49
  ais_user_subscribed,
50
50
  create_khoj_token,
51
51
  get_khoj_tokens,
52
+ get_user_by_email,
52
53
  get_user_name,
53
54
  get_user_notion_config,
54
55
  get_user_subscription_state,
@@ -230,7 +231,7 @@ def get_next_url(request: Request) -> str:
230
231
  return urljoin(str(request.base_url).rstrip("/"), next_path)
231
232
 
232
233
 
233
- def get_conversation_command(query: str, any_references: bool = False) -> ConversationCommand:
234
+ def get_conversation_command(query: str) -> ConversationCommand:
234
235
  if query.startswith("/notes"):
235
236
  return ConversationCommand.Notes
236
237
  elif query.startswith("/help"):
@@ -253,9 +254,6 @@ def get_conversation_command(query: str, any_references: bool = False) -> Conver
253
254
  return ConversationCommand.Code
254
255
  elif query.startswith("/research"):
255
256
  return ConversationCommand.Research
256
- # If no relevant notes found for the given query
257
- elif not any_references:
258
- return ConversationCommand.General
259
257
  else:
260
258
  return ConversationCommand.Default
261
259
 
@@ -407,42 +405,39 @@ async def aget_data_sources_and_output_format(
407
405
  response = clean_json(response)
408
406
  response = json.loads(response)
409
407
 
410
- selected_sources = [q.strip() for q in response.get("source", []) if q.strip()]
411
- selected_output = response.get("output", "text").strip() # Default to text output
408
+ chosen_sources = [s.strip() for s in response.get("source", []) if s.strip()]
409
+ chosen_output = response.get("output", "text").strip() # Default to text output
412
410
 
413
- if not isinstance(selected_sources, list) or not selected_sources or len(selected_sources) == 0:
411
+ if is_none_or_empty(chosen_sources) or not isinstance(chosen_sources, list):
414
412
  raise ValueError(
415
- f"Invalid response for determining relevant tools: {selected_sources}. Raw Response: {response}"
413
+ f"Invalid response for determining relevant tools: {chosen_sources}. Raw Response: {response}"
416
414
  )
417
415
 
418
- result: Dict = {"sources": [], "output": None if not is_task else ConversationCommand.AutomatedTask}
419
- for selected_source in selected_sources:
420
- # Add a double check to verify it's in the agent list, because the LLM sometimes gets confused by the tool options.
421
- if (
422
- selected_source in source_options.keys()
423
- and isinstance(result["sources"], list)
424
- and (len(agent_sources) == 0 or selected_source in agent_sources)
425
- ):
426
- # Check whether the tool exists as a valid ConversationCommand
427
- result["sources"].append(ConversationCommand(selected_source))
428
-
429
- # Add a double check to verify it's in the agent list, because the LLM sometimes gets confused by the tool options.
430
- if selected_output in output_options.keys() and (len(agent_outputs) == 0 or selected_output in agent_outputs):
431
- # Check whether the tool exists as a valid ConversationCommand
432
- result["output"] = ConversationCommand(selected_output)
433
-
434
- if is_none_or_empty(result):
416
+ output_mode = ConversationCommand.Text
417
+ # Verify selected output mode is enabled for the agent, as the LLM can sometimes get confused by the tool options.
418
+ if chosen_output in output_options.keys() and (len(agent_outputs) == 0 or chosen_output in agent_outputs):
419
+ # Ensure that the chosen output mode exists as a valid ConversationCommand
420
+ output_mode = ConversationCommand(chosen_output)
421
+
422
+ data_sources = []
423
+ # Verify selected data sources are enabled for the agent, as the LLM can sometimes get confused by the tool options.
424
+ for chosen_source in chosen_sources:
425
+ # Ensure that the chosen data source exists as a valid ConversationCommand
426
+ if chosen_source in source_options.keys() and (len(agent_sources) == 0 or chosen_source in agent_sources):
427
+ data_sources.append(ConversationCommand(chosen_source))
428
+
429
+ # Fallback to default sources if the inferred data sources are unset or invalid
430
+ if is_none_or_empty(data_sources):
435
431
  if len(agent_sources) == 0:
436
- result = {"sources": [ConversationCommand.Default], "output": ConversationCommand.Text}
432
+ data_sources = [ConversationCommand.Default]
437
433
  else:
438
- result = {"sources": [ConversationCommand.General], "output": ConversationCommand.Text}
434
+ data_sources = [ConversationCommand.General]
439
435
  except Exception as e:
440
436
  logger.error(f"Invalid response for determining relevant tools: {response}. Error: {e}", exc_info=True)
441
- sources = agent_sources if len(agent_sources) > 0 else [ConversationCommand.Default]
442
- output = agent_outputs[0] if len(agent_outputs) > 0 else ConversationCommand.Text
443
- result = {"sources": sources, "output": output}
437
+ data_sources = agent_sources if len(agent_sources) > 0 else [ConversationCommand.Default]
438
+ output_mode = agent_outputs[0] if len(agent_outputs) > 0 else ConversationCommand.Text
444
439
 
445
- return result
440
+ return {"sources": data_sources, "output": output_mode}
446
441
 
447
442
 
448
443
  async def infer_webpage_urls(
@@ -1363,6 +1358,49 @@ class FeedbackData(BaseModel):
1363
1358
  sentiment: str
1364
1359
 
1365
1360
 
1361
+ class EmailVerificationApiRateLimiter:
1362
+ def __init__(self, requests: int, window: int, slug: str):
1363
+ self.requests = requests
1364
+ self.window = window
1365
+ self.slug = slug
1366
+
1367
+ def __call__(self, request: Request):
1368
+ # Rate limiting disabled if billing is disabled
1369
+ if state.billing_enabled is False:
1370
+ return
1371
+
1372
+ # Extract the email query parameter
1373
+ email = request.query_params.get("email")
1374
+
1375
+ if email:
1376
+ logger.info(f"Email query parameter: {email}")
1377
+
1378
+ user: KhojUser = get_user_by_email(email)
1379
+
1380
+ if not user:
1381
+ raise HTTPException(
1382
+ status_code=404,
1383
+ detail="User not found.",
1384
+ )
1385
+
1386
+ # Remove requests outside of the time window
1387
+ cutoff = datetime.now(tz=timezone.utc) - timedelta(seconds=self.window)
1388
+ count_requests = UserRequests.objects.filter(user=user, created_at__gte=cutoff, slug=self.slug).count()
1389
+
1390
+ # Check if the user has exceeded the rate limit
1391
+ if count_requests >= self.requests:
1392
+ logger.info(
1393
+ f"Rate limit: {count_requests}/{self.requests} requests not allowed in {self.window} seconds for email: {email}."
1394
+ )
1395
+ raise HTTPException(
1396
+ status_code=429,
1397
+ detail="Ran out of login attempts",
1398
+ )
1399
+
1400
+ # Add the current request to the db
1401
+ UserRequests.objects.create(user=user, slug=self.slug)
1402
+
1403
+
1366
1404
  class ApiUserRateLimiter:
1367
1405
  def __init__(self, requests: int, subscribed_requests: int, window: int, slug: str):
1368
1406
  self.requests = requests
@@ -1642,7 +1680,7 @@ def scheduled_chat(
1642
1680
  last_run_time = datetime.strptime(last_run_time, "%Y-%m-%d %I:%M %p %Z").replace(tzinfo=timezone.utc)
1643
1681
 
1644
1682
  # If the last run time was within the last 6 hours, don't run it again. This helps avoid multithreading issues and rate limits.
1645
- if (datetime.now(timezone.utc) - last_run_time).total_seconds() < 21600:
1683
+ if (datetime.now(timezone.utc) - last_run_time).total_seconds() < 6 * 60 * 60:
1646
1684
  logger.info(f"Skipping scheduled chat {job_id} as the next run time is in the future.")
1647
1685
  return
1648
1686
 
@@ -57,7 +57,7 @@ def login_page(request: Request):
57
57
  if request.user.is_authenticated:
58
58
  return RedirectResponse(url=next_url)
59
59
  google_client_id = os.environ.get("GOOGLE_CLIENT_ID")
60
- redirect_uri = str(request.app.url_path_for("auth"))
60
+ redirect_uri = str(request.app.url_path_for("auth_post"))
61
61
  return templates.TemplateResponse(
62
62
  "login.html",
63
63
  context={
@@ -1,12 +1,13 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: khoj
3
- Version: 1.31.1.dev13
3
+ Version: 1.32.0
4
4
  Summary: Your Second Brain
5
5
  Project-URL: Homepage, https://khoj.dev
6
6
  Project-URL: Documentation, https://docs.khoj.dev
7
7
  Project-URL: Code, https://github.com/khoj-ai/khoj
8
8
  Author: Debanjum Singh Solanky, Saba Imran
9
- License: AGPL-3.0-or-later
9
+ License-Expression: AGPL-3.0-or-later
10
+ License-File: LICENSE
10
11
  Keywords: AI,NLP,images,markdown,org-mode,pdf,productivity,search,semantic-search
11
12
  Classifier: Development Status :: 5 - Production/Stable
12
13
  Classifier: Intended Audience :: Information Technology
@@ -63,6 +64,7 @@ Requires-Dist: pytz~=2024.1
63
64
  Requires-Dist: pyyaml~=6.0
64
65
  Requires-Dist: rapidocr-onnxruntime==1.3.24
65
66
  Requires-Dist: requests>=2.26.0
67
+ Requires-Dist: resend==1.0.1
66
68
  Requires-Dist: rich>=13.3.1
67
69
  Requires-Dist: schedule==1.1.0
68
70
  Requires-Dist: sentence-transformers==3.0.1
@@ -90,14 +92,12 @@ Requires-Dist: pytest-asyncio==0.21.1; extra == 'dev'
90
92
  Requires-Dist: pytest-django==4.5.2; extra == 'dev'
91
93
  Requires-Dist: pytest-xdist[psutil]; extra == 'dev'
92
94
  Requires-Dist: pytest>=7.1.2; extra == 'dev'
93
- Requires-Dist: resend==1.0.1; extra == 'dev'
94
95
  Requires-Dist: stripe==7.3.0; extra == 'dev'
95
96
  Requires-Dist: twilio==8.11; extra == 'dev'
96
97
  Provides-Extra: prod
97
98
  Requires-Dist: boto3>=1.34.57; extra == 'prod'
98
99
  Requires-Dist: google-auth==2.23.3; extra == 'prod'
99
100
  Requires-Dist: gunicorn==22.0.0; extra == 'prod'
100
- Requires-Dist: resend==1.0.1; extra == 'prod'
101
101
  Requires-Dist: stripe==7.3.0; extra == 'prod'
102
102
  Requires-Dist: twilio==8.11; extra == 'prod'
103
103
  Description-Content-Type: text/markdown