Compare commits

...

330 Commits

Author SHA1 Message Date
Debanjum
9258f57dce Release Khoj version 2.0.0-beta.28 2026-03-26 09:03:47 +05:30
Debanjum
171ac5d243 Add deprecation banner to top of web landing page as well 2026-03-26 08:59:05 +05:30
Debanjum
fdd5fd8f74 Fix getting billing config to show deprecation banner on Khoj cloud 2026-03-26 08:59:05 +05:30
Debanjum
8965db7087 Bump server, ciient dependencies 2026-03-26 08:59:05 +05:30
lif
8b8504edb8 Fix AttributeError when memories disabled and setting is None (#1296)
## Summary
- Add null checks for `config.setting` in `get_chat_model()` and
`aget_chat_model()` to prevent `AttributeError` when memories are
disabled
- When the memory toggle creates a `UserConversationConfig` via
`get_or_create` with `setting=None`, accessing
`config.setting.price_tier` crashes — now falls through to the default
chat model instead

## Root Cause
The "Enable Memories" toggle PATCH endpoint uses `get_or_create` on
`UserConversationConfig`, which can create a config with `setting=None`.
Both `get_chat_model()` and `aget_chat_model()` then crash:
- For subscribed users: `if config:` passes but `return config.setting`
returns `None`, causing downstream crashes
- For non-subscribed users: `config.setting.price_tier` raises
`AttributeError` on `None`

## Fix
Change `if config:` → `if config and config.setting:` (subscribed path)
and add `and config.setting` guard before `.price_tier` access
(non-subscribed path), in both sync and async variants.

## Test plan
- [ ] Toggle memories off with no prior chat model configured — settings
page should still load
- [ ] Chat responses should use default model when setting is None
- [ ] Existing users with configured chat models should be unaffected

Fixes #1287

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-03-26 08:26:25 +05:30
Debanjum
7475a781bc Release Khoj version 2.0.0-beta.27 2026-03-26 01:40:02 +05:30
Debanjum
a19e7acd5a Fix TemplateResponse calls to be compatible with Starlette 1.0.0
Starlette 1.0.0 removed the deprecated TemplateResponse signature
where `name` was the first positional arg and `request` was passed
inside `context`. The new signature requires `request` as the first
positional argument: TemplateResponse(request, name=...).

This caused a 500 error in production on web client endpoints with:
"Jinja2Templates.TemplateResponse() missing 1 required positional
argument: 'name'" (with older Starlette) or "'request'" (with 1.0.0).

Update all TemplateResponse calls in web_client.py to use the new
Starlette 1.0.0 signature: pass `request` as the first positional
arg and `name` as an explicit keyword argument.

Issue didn't trigger locally as uv is used locally and pip in docker
builds. These resolve dependencies including starletter version to
install differently. Locally 0.52.0 was installed while on production
starlette 1.0.0 was used. This is what caused the issue and the
mismatch in expectation
2026-03-26 01:38:41 +05:30
Debanjum
b8797e00fa Release Khoj version 2.0.0-beta.26 2026-03-25 20:37:55 +05:30
Debanjum
f356386f3a Ignore dup file errors for pypi wheel validation. Expected Next 15 behavior 2026-03-25 20:30:12 +05:30
Debanjum
d4df9a73ec Use next Link instead of raw a html tags to wrap more links on web app 2026-03-25 20:07:43 +05:30
Debanjum
f7bce48934 Show Khoj cloud deprecation banner on web app to Khoj cloud users
Add banner to home, chat, shared chat and settings pages for coverage.
Link to settings account section to export data and mention Khoj
self-host option in banner
2026-03-25 19:31:53 +05:30
Debanjum
b8f82b27f5 Use next Link instead of raw a html tags to wrap Khoj home logo link 2026-03-25 19:14:59 +05:30
Debanjum
a9749c7184 Upgrade to Next.js 15 for web app 2026-03-25 18:32:48 +05:30
Debanjum
51a56af7ca Skip automation tests when GEMINI_API_KEY is not set
- Add missing skipif decorator to test_create_automation
- Change skip condition from 'is None' to 'not' (falsy check) to
  also handle empty string, which happens when GitHub secrets are
  unavailable in fork PRs
2026-03-25 18:09:24 +05:30
Debanjum
7264ebf533 Bump package dependencies
Changes (4 files):
- pyproject.toml: authlib 1.6.6 → 1.6.9
- src/interface/web/package.json: dompurify ^3.2.6 → ^3.3.2, eslint-config-next 14.2.3 → 14.2.35
- documentation/package.json: @docusaurus/* → ^3.9.2, added serialize-javascript resolution

And regenerated lock files.

The only resolution override is serialize-javascript in documentation,
which is unavoidable since Docusaurus still pins old
copy-webpack-plugin and css-minimizer-webpack-plugin that depend on
serialize-javascript ^6.x.
2026-03-25 18:09:12 +05:30
Tay
0e169159f8 Close leaked file handle in orgnode parser (#1284)
## Summary

`src/khoj/processor/content/org_mode/orgnode.py:57` opens a file with
`open(filename, "r")` but never closes it. The file handle leaks for the
lifetime of the returned `Orgnode` list.

## Fix

Replaced bare `open()` with a `with` statement to ensure the file is
closed after `makelist()` finishes reading.

```python
# Before
def makelist_with_filepath(filename):
    f = open(filename, "r")
    return makelist(f, filename)

# After
def makelist_with_filepath(filename):
    with open(filename, "r") as f:
        return makelist(f, filename)
```

This is safe because `makelist()` fully consumes the file during the
call (building the Orgnode list from file contents), so the file handle
is no longer needed after it returns.
2026-03-25 18:03:20 +05:30
BillionToken
530443a4f6 Fix UnboundLocalError in PdfToEntries.extract_text when PDF processing fails (#1292)
When PyMuPDFLoader fails to process an invalid PDF file, the exception
is caught but pdf_entry_by_pages is referenced before assignment, 
causing an UnboundLocalError.

Initialized pdf_entry_by_pages to an empty list before the try block so 
the return statement always has a valid value, even when an exception
occurs.

Verified with both invalid input (returns []) and valid PDFs (returns
extracted text).

Fixes #1289

Co-authored-by: BillionClaw <267901332+BillionClaw@users.noreply.github.com>
2026-03-25 17:47:50 +05:30
yang1002378395-cmyk
e863126140 fix: ChatModel.__str__ returns None when friendly_name is null (#1277)
## Problem
When `ChatModel.friendly_name` is `None`, the `__str__` method returns
`None`, causing:
```
TypeError: __str__ returned non-string (type NoneType)
```

## Solution
Fall back to `name` field when `friendly_name` is `None`.

Related issue: #1251

Co-authored-by: 阳虎 <yanghu@yanghudeMacBook-Pro.local>
2026-03-20 00:58:44 +05:30
jnMetaCode
678549c6b0 Fix extract_from_webpage discarding pre-fetched content (#1269)
## Summary

In `extract_from_webpage()`, the `content` parameter is unconditionally
overwritten to `None` on the line before the `is_none_or_empty(content)`
check. This means any pre-fetched content (e.g. text content already
retrieved by the Exa search engine) is always discarded, forcing an
unnecessary re-scrape of the webpage.

## Bug

```python
async def extract_from_webpage(
    url: str,
    subqueries: set[str] = None,
    content: str = None,     # <-- caller passes pre-fetched content
    ...
) -> Tuple[set[str], str, Union[None, str]]:
    content = None            # <-- BUG: immediately overwrites it
    if is_none_or_empty(content):  # always True
        content = await scrape_webpage_with_fallback(url)
```

## Fix

Remove the `content = None` assignment so the passed-in content is used
when available, falling back to scraping only when needed.

This bug was introduced in a refactor and causes:
- Wasted API calls to web scrapers for pages whose content is already
available
- Increased latency for search results that include inline content (e.g.
Exa)

Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-17 10:33:52 +05:30
jnMetaCode
6735d33af2 Fix operator precedence in research iteration counter (#1271)
## Summary

Fix a Python operator precedence bug in the `research()` function that
causes `current_iteration` to be set to a boolean instead of the actual
count of previous iterations.

## Bug

```python
if current_iteration := len(previous_iterations) > 0:
```

Python evaluates this as:
```python
if current_iteration := (len(previous_iterations) > 0):  # assigns True or False
```

So `current_iteration` becomes `True` (1) or `False` (0) regardless of
how many previous iterations exist.

## Fix

```python
if (current_iteration := len(previous_iterations)) > 0:
```

With parentheses, `current_iteration` is correctly set to the count
(e.g. 4), and then compared to 0.

## Impact

When resuming research with previous iterations, the loop counter was
effectively reset to 1 instead of the true count. This allowed the
research loop to run significantly more iterations than `MAX_ITERATIONS`
intended, wasting compute and API calls.

Signed-off-by: JiangNan <1394485448@qq.com>
2026-03-17 10:28:56 +05:30
Olexandr88
0e9878c070 Remove redundant SDK version check in LauncherActivity of Android app (#1263)
Remove redundant SDK version check in LauncherActivity since both
branches set the same orientation value. This simplifies the code
without changing behavior

Signed-off-by: Olexandr88 <radole1203@gmail.com>
2026-03-06 12:10:20 +05:30
layla
2c82967807 Fix typos in telemetry error message and comment (#1265)
Fix spelling typos in telemetry.py. Corrects 'recieved' to 'received'
and 'equest' to 'request' in comments and error messages.
2026-03-06 12:04:37 +05:30
Debanjum
17be2d4800 Update Pipali project announcement in README 2026-03-06 12:02:50 +05:30
Debanjum
aeea140099 Update What's New in Readme - Mention Pipali release 2026-03-05 21:31:07 -08:00
saba imran
b864cb1f30 only show the payment card on the settings page if the user is subscribed 2026-03-02 10:35:09 -08:00
Debanjum
0b8cf5112f Drop trailing slash to get memories via api on web app in production
Trailing slash in api calls to server doesn't work in production
behind proxy, only in local next.js dev server.
2026-02-24 10:46:42 -08:00
Debanjum
94bae4789a Release Khoj version 2.0.0-beta.25 2026-02-22 11:56:01 -08:00
lif
5a51f17a71 Fix AttributeError when Eleven Labs API key is not set (#1238)
## Summary
- Fixes AttributeError: 'str' object has no attribute 'iter_content' in
text_to_speech endpoint
- When `ELEVEN_LABS_API_KEY` is not configured, the function was
returning a string instead of a Response object

## Changes
- Introduced `TextToSpeechError` exception class in `text_to_speech.py`
- Changed `generate_text_to_speech` to raise exception instead of
returning error string
- Updated API endpoint to catch the exception and return HTTP 501 (Not
Implemented)

## Test plan
- [x] Code passes ruff lint check
- [ ] Manual testing with and without Eleven Labs API key configured

Fixes #1049

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
Co-authored-by: Debanjum <debanjum@gmail.com>
2026-02-23 01:23:57 +05:30
Koshik Debanath
b0cd8dc8fd Add ability to copy references from Web App (#1144)
Add a "Copy References" button to the references pane in the web app.

In ReferencePanel Component
- Add a "Copy References" button to the `ReferencePanel` component.
- Implement functionality to copy all references (notes, online, and
code) as a markdown bullet list.
- Update the `TeaserReferencesSection` component to include the "Copy
References" button.
- Show copied to clipboard indicator when references copied on button click

Closes #1021

---------

Co-authored-by: Debanjum <debanjum@gmail.com>
2026-02-23 01:11:45 +05:30
Sam Ho
0ba0f3d0f2 Show autocomplete suggestions for File Query Filters on Obsidian App (#1128)
- When you type in search modal, and matches the pattern `file:`, you
should see list of all files in vault and non-vault
- This list is filtered down as you type more letters 


### Technical Details

- Added file filter mode (`isFileFilterMode` state) to filter search
results by specific files
- Updated `getSuggestions()` function to search file from vault and
non-vault via khoj backend.
- Updated the selection behavior to handle both file selection and
search result selection

Closes https://github.com/khoj-ai/khoj/issues/1025

---------

Co-authored-by: Debanjum <debanjum@gmail.com>
2026-02-23 00:33:17 +05:30
Debanjum
60a2f6d4da Constrain setuptools to resolve installing server in ci
Error due openai-whisper depending on pkg_resource that setuptools
seems to have dropped.

See https://github.com/pypa/setuptools/issues/5174 for reference

Also bump pillow version
2026-02-22 10:48:04 -08:00
Debanjum
9dfef3f40b Bump more server dependencies and update uv lock file too 2026-02-22 10:28:53 -08:00
Debanjum
44b9240253 Bump server, desktop, obsidian and docs dependencies 2026-02-22 09:19:50 -08:00
Debanjum
21c51b9ace Ensure only serve home landing page files under specified path 2026-02-22 09:19:41 -08:00
Debanjum
9cbf620e45 Retry (with fallback) on Gemini fails with internal server error
Khoj should use a fallback model when available to retry request if
calls to Gemini fail with internal server error.
2026-01-06 12:14:08 -08:00
Henri Jamet
1fd6e16cff Improve Obsidian Batch Sync. Show Progress, Storage Used on Settings Page (#1221)
### **feat(obsidian): Enhance Sync Experience with Progress Bars and Bug
Fixes**

This pull request significantly improves the content synchronization
experience for Obsidian users by fixing a critical bug and introducing
new UI elements for better feedback and monitoring.

The previous implementation could fail with `403 Forbidden` errors when
syncing a large number of files due to server-side rate limiting. This
update addresses that issue and provides users with clear, real-time
feedback on storage usage and sync progress.

---
### Key Changes

* **Improve Sync Robustness**
Refactor `updateContentIndex` to sync files prioritized by file type (md
> pdf > image) and batched by size (10Mb) and item limits (50 items).
This respects server rate limits and ensures that large vaults can be
indexed reliably without triggering `403` errors.

* **Show Cloud Storage Usage Bar**
A progress bar has been added to the settings page to display cloud
storage usage.
* **Total Limit**: The storage limit (**10 MB** for free, **500 MB** for
premium) is now reliably determined by the `is_active` flag returned
from the `/api/v1/user` endpoint, eliminating fragile client-side
heuristics.
* **Used Space**: The used space is calculated via a **client-side
estimation** of all files configured for synchronization. This provides
a clear and immediate indicator of the vault's storage footprint.

* **Show Real-time Sync Progress Bar**
When a manual sync is triggered via the "Force Sync" button, a progress
bar now appears, providing real-time feedback on the operation.
* It displays the number of files processed against the total number of
files to be indexed or deleted.
* This is implemented using a **callback mechanism** (`onProgress`) to
cleanly communicate progress from the sync logic (`utils.ts`) to the UI
(`settings.ts`) without coupling them.

* **Auto-refresh Storage Used After Sync**
The Cloud Storage Usage bar is now automatically refreshed upon the
completion of a "Force Sync". This ensures the user immediately sees the
updated storage estimation without needing to reopen the settings panel.

---
### Visuals

<img width="980" height="237" alt="image"
src="https://github.com/user-attachments/assets/2b3ce420-766b-476f-9fc0-c6b38c0226fb"
/>

---------

Co-authored-by: Debanjum <debanjum@gmail.com>
2026-01-03 13:04:46 +05:30
sabaimran
d6c2d1fa49 Give Khoj Long Term Memories (#1168)
# Motivation
A major component of useful AI systems is adaptation to the user
context. This is a major reason why we'd enabled syncing knowledge
bases. The next steps in this direction is to dynamically update the
evolving state of the user as conversations take place across time and
topics. This allows for more personalized conversations and to maintain
context across conversations.

# Overview
This change introduces medium and long term memories in Khoj. 
- The scope of a conversation can be thought of as short term memory. 
- Medium term memory extends to the past week.
- Long term memory extends to anytime in the past, where a search query
results in a match.

# Details
- Enable user to view and manage agent generated memories from their
settings page
- Fully integrate the memory object into all downstream usage, from
image generation, notes extraction, online search, etc.
- Scope memory per agent. The default agent has access to memories
created by other agents as well.
- Enable users and admins to enable/disable Khoj's memory system

---------

Co-authored-by: Debanjum <debanjum@gmail.com>
2026-01-03 09:07:05 +05:30
Debanjum
d55a00288b Release Khoj version 2.0.0-beta.24 2026-01-01 19:59:10 -08:00
Debanjum
ff4b9f3502 Render mermaid diagram wrapped in markdown codeblocks on web app 2026-01-01 19:57:49 -08:00
Debanjum
19900e42ef Fix registering subscription payment failures 2026-01-01 19:57:49 -08:00
Debanjum
a58ae3dd84 Make LLM actors write & code sandbox check for artifacts in /home/user
Fix
- Ensure researcher and coder know to save files to /home/user dir
- Make E2B code executor check for generated files in /home/user
- Do not re-add file types already downloaded from /home/user

Issues
- E2B has a mismatch in default home_dir for run_code & list_dir cmds
So run_code was run with /root as home dir. And list_dir("~") was
checking under /home/user. This caused files written to /home/user
by code not to be discovered by the list_files step.
- Previously the researcher did not know that generated files should
be written to /home/user. So it could tell the coder to save files to
a different directory. Now the researcher knows where to save files to
show them to user as well.
2025-12-29 14:57:37 -08:00
Debanjum
b607a6187e Release Khoj version 2.0.0-beta.23 2025-12-29 01:42:25 -08:00
Boris Smus
f413ce7354 Enable excluding folders to sync from obsidian plugin settings (#1235)
- Add excludeFolders field to KhojSetting interface
- Rename 'Sync Folders' to 'Include Folders' for clarity
- Add 'Exclude Folders' UI section with folder picker
- Filter out excluded folders during content sync
- Show file counts when syncing (X of Y files)
- Prevent excluding root folder

This allows users to exclude specific directories (e.g., Inbox,
Highlights) from being indexed, while the existing Include Folders acts
as a whitelist.

---------

Co-authored-by: Debanjum <debanjum@gmail.com>
2025-12-29 15:09:19 +05:30
Debanjum
9b9cdc756f Capture more files generated by code execution in sandbox
This change had been removed in 9a8c707 to avoid overwrites. We now
use random filename for generated files to avoid overwrite from
subsequent runs.

Encourage model to write code that writes files in home folder to
capture with logical filenames.
2025-12-29 00:57:17 -08:00
Debanjum
c5650f166a Make Nano Banana Pro output 2K resolution images 2025-12-29 00:57:17 -08:00
Debanjum
1b7ccd141d Harden the user check of the Notion integration 2025-12-29 00:57:17 -08:00
Debanjum
b8eeefa0b1 Bump web app package dependencies 2025-12-29 00:57:17 -08:00
Debanjum
9801ffd2de Add Khoj app landing page. Show it when unauthenticated users open app
Add khoj app landing page to khoj monorepo. Show in a more natural
place, when non logged in users open the khoj app home page.

Authenticated users still see logged in home page experience.
2025-12-29 00:57:17 -08:00
Debanjum
5e65754a8b Unify login via popup on home. No need for separate login html page.
Delete old login html page. Login via popup on home is the single,
unified login experience.

Have docs mention khoj home url, no need to mention /login as login
popup shows on home page too
2025-12-29 00:57:17 -08:00
Debanjum
f65f6ae848 Fix streaming thoughts from multi-turn tools after parallel tool calling
Single turn tools are still executed in parallel. Multi turn tools
like operator are executed in serial.
2025-12-29 00:57:17 -08:00
Debanjum
446a23524c Remove trial subscriptions from Khoj cloud 2025-12-16 16:23:47 -08:00
Debanjum
cdcbdf8459 Execute tool calls in parallel to make research iterations faster 2025-12-13 22:55:54 -08:00
Debanjum
054ed79fdf Allow LLMs to make parallel tool call requests
Why
--
- The models are now smart enough to usually understand which tools to
  call in parallel and when.

- The LLM can request more work for each call to it, which is usually
  the slowest step. This speeds up work by reearch agent. Even though
  each tool is still executed in sequence (for now).
2025-12-13 21:37:49 -08:00
Debanjum
f4c519a9d0 Release Khoj version 2.0.0-beta.22 2025-12-07 20:32:28 -08:00
Debanjum
6480e99266 Upgrade server, documentation dependencies 2025-12-07 20:29:11 -08:00
Debanjum
181332dcb8 Improve Claude context caching to improve response cost, intelligence
Old thought messages are dropped by default by the Anthropic API. This
change ensures old thoughts are kept. This should improve cache
utilization to reduce costs. And keeping old thoughts may also improve
model intelligence.
2025-12-07 20:23:58 -08:00
Debanjum
9c03af2735 Disable parallel tool call by anthropic models as unsupported
Khoj doesn't handle parallel tool calling right now. Models were told
to call tools in serial but it wasn't enforced via the Anthropic API.
So if model did try make parallel tool call, next response would fail
as it expects a tool result for the other tool calls. But khoj just
returned the first tool calls results. This mostly affected haiku due
to its lower fine-grained instruction following capabilities.

This changes enforces serial tool calls at the API layer to avoid this
issue altogether for claude models.
2025-12-07 20:23:58 -08:00
Debanjum
4654ac4962 Enable reasoning for Claude Haiku 4.5. Track costs of Opus 4.5 2025-12-07 20:23:58 -08:00
Debanjum
f1337c3b07 Handle unset event in chat response stream
Testing unset event field is an edge case that would unnecessarily
prematurely terminate stream. Ignoring it stabilizes response stream
completion.
2025-12-07 20:23:58 -08:00
Debanjum
bdf9afa726 Set openai api output tokens high by default to not hit length limits
Explicitly set completion tokens high to avoid early termination
issues, especially when trying to generate structured responses.
2025-12-07 20:23:58 -08:00
Debanjum
5b6dab1627 Use consistent summarizer result for failed research iteration 2025-12-05 10:02:08 -08:00
Debanjum
3941159bd6 Release Khoj version 2.0.0-beta.21 2025-11-29 17:31:14 -08:00
Debanjum
e2340c709f Fix extracting image by URL in chat history when using Nano Banana Pro
Logical error due to else conditional being not correctly indented.
This would result in error in using gemini 3 pro image when images are
in S3 bucket.
2025-11-29 17:08:17 -08:00
Debanjum
856864147b Drop image generation support for Stability AI models
Reduces maintenance burden by dropping support for old ai model
providers
2025-11-29 17:08:11 -08:00
Debanjum
c41e37d734 Release Khoj version 2.0.0-beta.20 2025-11-29 16:12:38 -08:00
Debanjum
731700ac43 Support fallback deep, fast chat models via server chat settings
Overview
---
This change enables specifying fallback chat models for each task
type (fast, deep, default) and user type (free, paid).

Previously we did not fallback to other chat models if the chat model
assigned for a task failed.

Details
---
You can now specify multiple ServerChatSettings via the Admin Panel
with their usage priority. If the highest priority chat model for the
task, user type fails, the task is assigned to a lower priority chat
model configured for the current user and task type.

This change also reduces the retry attempts for openai chat actor
models from 3 to 2 as:
- multiple fallback server chat settings can now be created. So
  reducing retries with same model reduces latency.
- 2 attempts is inline with retry attempts with other model
  types (gemini, anthropic)
2025-11-29 15:57:35 -08:00
Debanjum
99f16df7e2 Use fast model in default mode and for most chat actors
What
--
- Default to using fast model for most chat actors. Specifically in this
  change we default to using fast model for doc, web search chat actors
- Only research chat director uses the deep chat model.
- Make using fast model by chat actors configurable via func argument

Code chat actor continues to use deep chat model and webpage reader
continues to use fast chat model.

Deep, fast chat models can be configured via ServerChatSettings on the
admin panel.

Why
--
Modern models are good enough at instruction following. So defaulting
most chat actor to use the fast model should improve chat speed with
acceptable response quality.

The option to fallback to research mode for higher quality
responses or deeper research always exists.
2025-11-29 15:57:35 -08:00
Debanjum
da493be417 Support image generation with Gemini Nano Banana 2025-11-29 15:57:35 -08:00
Debanjum
dd4381c25c Do not try render invalid image paths in message on web app
Avoids rendering flicker from attempt to render invalid image paths
referenced in message by khoj on web app.

The rendering flicker made it very annoying to interact with
conversations containing such messages on the web app.

The current change does lightweight validation of image url before
attempting to render it. If invalid image url detected, the image is
replaced with just its alt text.
2025-11-29 15:23:51 -08:00
Debanjum
51b893d51d Fix to ensure rectangular generated images are not cropped on web app
Previously non-square images would get cropped when being displayed on
web app
2025-11-29 15:23:51 -08:00
Debanjum
32966646e2 Avoid ai hover summaries in vscode dev env for now 2025-11-29 15:23:51 -08:00
Debanjum
043777c1bd Release Khoj version 2.0.0-beta.19 2025-11-18 15:30:50 -08:00
Debanjum
47a55c20a0 Associate folder icon with all doc tools use in thinking UX on web app
The newer grep_files and list_files should also be associated with
document search in train of thought visualization on the web app.
2025-11-18 15:17:38 -08:00
Debanjum
6459150870 Upgrade packages for documentation and desktop app 2025-11-18 15:17:38 -08:00
Debanjum
03dad1348a Support Minimax M2. Extract its thinking from response
- Use qwen style <think> tags to extract Minimax M2 model thoughts
- Use function to mark models that use in-stream thinking (including
  Kimi K2 thinking)
2025-11-18 14:13:28 -08:00
Debanjum
57d6ebb1b8 Support Google Gemini 3
- Use thinking level for gemini 3 models instead of thinking budget.
- Bump google gemini library
- Add default context, pricing
2025-11-18 14:13:24 -08:00
Debanjum
a30c5f245d Skip non-serializable, binary content parts when token counting 2025-11-18 12:42:18 -08:00
Debanjum
ec31df7154 Test khoj.el with more recent emacs versions 2025-11-18 10:29:02 -08:00
Debanjum
895af42039 Fix unbound response var exception in agent safety checker 2025-11-18 10:29:02 -08:00
Debanjum
748a4f9941 Release Khoj version 2.0.0-beta.18 2025-11-16 11:08:44 -08:00
Debanjum
3496189618 Support using MCP tools in research mode
- Server admin can add MCP servers via the admin panel
- Enabled MCP server tools are exposed to the research agent for use
- Use MCP library to standardize interactions with mcp servers
  - Support SSE or Stdio as transport to interact with mcp servers
  - Reuse session established to MCP servers across research iterations
2025-11-16 10:50:30 -08:00
Debanjum
2ac7359092 Simplify webpage read function names and drop unused return args 2025-11-16 10:50:30 -08:00
Debanjum
f1a34f0c2a Prefer Exa for web search over Google, Firecrawl
Google and Firecrawl do not provide good web search descriptions (within
given latency requirements). Exa does better than them.

So prioritize using Exa over Google or Firecrawl when multiple web
search providers available.
2025-11-16 10:50:30 -08:00
Debanjum
45f4253120 Move Olostep scraping config into its webpage reader for cleaner code 2025-11-16 10:50:30 -08:00
Debanjum
e6a5d3dc3d Deprecate support for using Firecrawl webpage summarizer
Better speed and control by using Khoj webpage summarizer. Reduce code
cruft by clearing unused features.
2025-11-16 10:50:30 -08:00
Debanjum
0415b31a23 Upgrade Firecrawl web provider to use their v2 api 2025-11-16 10:50:30 -08:00
Debanjum
61cb2d5b7e Enable webpage reading with Exa. Remove Jina web page reader
Support using Exa for webpage reading. It seems much faster than
currently available providers.

Remove Jina as a webpage reader and remaining references to Jina from
code, docs. It was anyway slow and API may shut down soon (as it was
bought by Elastic).

Update docs to mention Exa for web search and webpage reading.
2025-11-16 10:50:30 -08:00
Debanjum
d57c597245 Refactor count_tokens, get_encoder methods to utils/helper.py
Simplify get_encoder to not rely on global state. The caching
simplification is not necessary for now.
2025-11-16 10:50:30 -08:00
Debanjum
15482c54b5 Fix type of count total tokens system_message argument 2025-11-16 10:50:30 -08:00
Debanjum
761af5f98c Drop spurious results close xml tag in research shared with llm 2025-11-16 10:50:30 -08:00
Debanjum
1f3c1e1221 Remove spurious comments in desktop app chatutils.js 2025-11-16 10:50:30 -08:00
Debanjum
8490f2826b Reduce evaluator llm verbosity during eval 2025-11-16 10:50:30 -08:00
Debanjum
630ce77b5f Improve agent update/create safety check. Make reason field optional
Issue
---
When agent personality/instructions are safe, we do not require the
safety agent to give a reason. The safety check agent was told this in
the prompt but it was not reflected in the json schema being used.

Latest openai library started throwing error if response doesn't match
requested json schema.

This broke creating/updating agents when using openai models as safety
agent.

Fix
---
Make reason field optional.

Also put send_message_to_model_wrapper in try/catch for more readable
error stacktrace.
2025-11-14 06:47:51 -08:00
Debanjum
cbeb220f00 Show validation errors in UX if agent creation, update fails
Previously we only showed unsafe prompt errors to user when
creating/updating agent. Errors in name collision were not shown on
the web app ux.

This change ensures that such validation errors are bubbled up to the
user in the UX. So they can resolve the agent create/update error on
their end.
2025-11-12 10:51:07 -08:00
Debanjum
d7e936678d Release Khoj version 2.0.0-beta.17 2025-11-11 16:38:46 -08:00
Debanjum
4556773f42 Improve support for new kimi k2 thinking model
Recognize thinking by kimi k2 thinking model in <think> xml blocks
2025-11-11 16:20:41 -08:00
Debanjum
2c54a2cd10 Improve web browsing train of thought status text shown on web app 2025-11-11 16:14:27 -08:00
Debanjum
aab0653025 Add price for grok 4, grok 4 fast for cost estimatation 2025-11-11 16:14:19 -08:00
Debanjum
b14e6eb069 Count cache, reasoning tokens to estimate cost for models served over openai api
Count cached tokens, reasoning tokens for better cost estimates for
models served over an openai compatible api. Previously we didn't
include cached token or reasoning tokens in costing.
2025-11-11 16:12:48 -08:00
Debanjum
ce6d75e5a2 Support Exa as web search provider 2025-11-11 16:12:48 -08:00
Debanjum
5760f3b534 Drop support for web search, read using Jina as provider
There are faster, better web search, webpage read providers. Only keep
reasonable quality online context providers.

Jina was good for self-hosting quickstart as it provided a free api
key without login. It does not provide that now. Its latencies are
pretty high vs other online context providers.
2025-11-11 16:12:48 -08:00
Debanjum
c022e7d553 Upgrade Anthropic Operator editor version 2025-11-11 16:12:48 -08:00
Debanjum
88a1fc75cc Track cost of claude haiku 4.5 model 2025-11-11 16:12:48 -08:00
Debanjum
a809de8970 Handle skip indexing of unsupported image files
Previously unsupported image file types would trigger an unbound local
variable error.
2025-11-11 16:09:08 -08:00
Debanjum
749bbed23d Track cost of claude sonnet 4.5 models 2025-11-11 16:09:04 -08:00
Debanjum
69cceda9ab Bump server dependencies 2025-11-11 16:08:38 -08:00
Debanjum
140a3ef943 Avoid unbound chunk variable error in ai api call from completion func 2025-11-11 16:08:38 -08:00
Debanjum
f2e0b62217 Remove unused default source from default tool picker prompt, help msg 2025-11-11 16:08:37 -08:00
Debanjum
5ef3a3f027 Remove unused eval workflow config to auto read webpage in default mode 2025-09-16 14:55:06 +05:30
Debanjum
6ac2280e41 Release Khoj version 2.0.0-beta.16 2025-09-16 14:47:14 +05:30
Debanjum
1179a4c8f8 Update dev docs to suggest using bun instead of yarn for web app
Resolves #1218
2025-09-16 14:13:34 +05:30
Debanjum
51e5c86fcc Bump desktop app dependencies 2025-09-16 14:03:58 +05:30
Debanjum
534ee32664 Bump web app dependencies 2025-09-16 14:03:58 +05:30
Debanjum
e854c1a5a8 Bump django, langchain python server dependencies 2025-09-16 14:03:58 +05:30
Debanjum
2fdb1fcc93 Remove unsupported tool schema fields minimum, maximum for groq api
Groq API has stopped support minimum and maximum items fields from
tool schema. This unexpectedly broke using AI models served via Groq
API like Kimi K2 and GPT-OSS in research mode.

Improve typing of relevant fields
2025-09-16 14:03:29 +05:30
Debanjum
3e699e5476 Fix date time rendering when print conversation on web app 2025-09-01 07:09:32 -07:00
Debanjum
0bd4bf182c Show shared chats without login popup shown to unauthenticated users
The login popup is an unnecessary distraction as you do not need
to be logged in to view shared chats.
2025-08-31 23:40:09 -07:00
Debanjum
52b1928023 Make gpqa answer evaluator more versatile at extracting mcq answers 2025-08-31 23:40:09 -07:00
Debanjum
703e189979 Deterministically shuffle dataset for consistent data in a eval run
Previously eval run across modes would use different dataset shuffles.

This change enables a strict apples to apples perf comparison of the
different khoj modes across the same (random) subset of questions by
using a dataset seed per workflow run to sample questions
2025-08-31 23:40:08 -07:00
Debanjum
edf9ea6312 Release Khoj version 2.0.0-beta.15 2025-08-31 13:21:58 -07:00
Debanjum
d53ede604c Only enable web search with Searxng if KHOJ_SEARXNG_URL env var set
Instead of implicitly defaulting to assuming it is available as:
- For pip install searxng has to be explicitly setup to work
- For docker install we explicitly do set it up and set the
  KHOJ_SEARXNG_URL env var already

Also check if Searxng URL is also unset before disable web search
tools now that it is required explicit enablement.
2025-08-31 13:17:05 -07:00
Debanjum
7533e3eecf Use prompt cache key to improve cache hits with openai responses api
Using prompt cache key enables sticky routing to openai servers.
This increases probability of a chat actor hitting same server and
reusing cached prompts.

We use stable hash of first N characters to uniquely identify a chat
actor prompt
2025-08-31 12:44:38 -07:00
Debanjum
3c1948e9de Disable code sandbox if no code sandbox configured by admin
Either set the Terrarium sandbox url or the E2B api key to enable code
sandbox
2025-08-31 10:15:14 -07:00
Debanjum
3441783d5b Disable web search tool if no search engine configured by admin
Webpage read is gated behind having a web search engine configured for
now. It can later be decoupled from web search and depend on whether
any web scrapers is configured.
2025-08-30 00:22:26 -07:00
Debanjum
3aa6f8ba1f Add gemini cached tokens costs for more accurate cost tracking 2025-08-29 15:55:07 -07:00
Debanjum
0babab580a Avoid null ref error when no organic online search results found 2025-08-29 15:54:06 -07:00
Debanjum
00f0d23224 Fix indexing Github, Notion content by linking embeddings model on init 2025-08-29 15:54:06 -07:00
Debanjum
81c651b5b2 Fix truncation tests to check output chat history for truncation
New truncation logic return a new message list.
It does not update message list by reference/in place since 8a16f5a2a.
So truncation tests should run verification on the truncated chat
history returned by the truncation func instead of the original chat
history passed into the truncation func.
2025-08-28 15:50:32 -07:00
Debanjum
c0f192b436 Set minimum table width on web app for better readability 2025-08-28 01:58:04 -07:00
Debanjum
dd8e805cfe Add support for Cerebras ai model api
- It does not support strict mode for json schema, tool use
- It likes text content to be plain string, not nested in a dictionary
- Verified to work with gpt oss models on cerebras
2025-08-28 01:57:39 -07:00
Debanjum
0a5a882e54 Check if openai compatible ai api supports the responses api endpoint
Responses API is starting to get supported by other ai apis as well.
This change does preparatory improvements to ease moving to use
responses api with other ai apis.

Use the new, better named `supports_responses_api' method.
The method currently just maps to `is_openai_api'. It will add other
ai apis once support for using responses api with them is added.
2025-08-28 01:38:47 -07:00
Debanjum
9395c17f34 Fix openai reasoning model handling
- Fix identifying gpt-oss as openai reasoning model
- Drop unsupported stop param for openai reasoning models
- Drop the Formatting re-enabled logic for openai reasoing only models
  We use responses api for openai models and latest openai models are
  hybrid models, they don't seem to need this convoluted system
  message to format response as markdown
2025-08-28 01:38:47 -07:00
Debanjum
be79b8a633 Drop unused arguments to default tool picker, research mode
is_automated_task check isn't required as automation cannot be created
via chat anymore.

conversation specific file_filters are extracted directly in document
search, so doesn't need to be passed down from chat api endpoint
2025-08-27 14:37:28 -07:00
Debanjum
9d7adbcbaa Pass user attached images to default tool picker for informed selection
Previously we were just passing placeholder informing the default mode
tool picker that images were attached.
2025-08-27 14:37:10 -07:00
Debanjum
2091044db5 Prefer agent chat model to extract document search queries
Make chat model preference order for document search consistent with
all other tools.
2025-08-27 13:55:50 -07:00
Debanjum
7a42042488 Share context builder for chat final response across model types
The context building logic was nearly identical across all model
types.

This change extracts that logic into a shared function and calls it
once in the `agenerate_chat_response', the entrypoint to the converse
methods for all 3 model types.

Main differences handled are
- Gemini system prompt had additional verbosity instructions. Keep it
- Pass system messsage via chatml messages list to anthropic, gemini
  models as well (like openai models) instead of passing it as
  separate arg to chat_completion_* funcs.

  The model specific message formatters for both already extract
  system instruction from the messages list. So system messages wil be
  automatically extracted from the chat_completion_* funcs to pass as
  separate arg required by anthropic, gemini api libraries.
2025-08-27 13:48:33 -07:00
Debanjum
02e220f5f5 Pass args to context builder funcs grouped consistently
Put context params together, followed by model params
Use consistent ordering to improve readability
2025-08-27 13:45:36 -07:00
Debanjum
4976b244a4 Set fast, deep think models for intermediary steps via admin panel
Overview
Enable improving speed and cost of chat by setting fast, deep think
models for intermediate steps and non user facing operations.

Details
- Allow decoupling default chat models from models used for
  intermediate steps by setting server chat settings on admin panel
- Use deep think models for most intermediate steps like tool
  selection, subquery construction etc. in default and research mode
- Use fast think models for webpage read, chat title setting etc.
  Faster webpage read should improve conversation latency
2025-08-27 13:45:36 -07:00
Debanjum
a99eb841ff Do not search documents when default tool selected by agent
What
Explicit selection of notes tool/conversation command by agent is
required now.

Why
- Newer models are good at deciding when to look up notes
- Modern khoj is less of a notes only chat to search notes by default
2025-08-27 13:45:28 -07:00
Debanjum
a52a06ad9d Prefer olostep over firecrawl for webpage read by default
Default to Olostep as faster and higher webpage read success rate.
Fallback logic will use Firecrawl if Olostep fails.
2025-08-27 13:45:09 -07:00
Debanjum
e150dc5a91 Improve copying message with math, file links to clipboard on web app 2025-08-27 13:45:09 -07:00
Debanjum
15d1f39d0b Improve instruction to ai model for writing math expressions in LaTeX 2025-08-27 13:45:09 -07:00
Debanjum
05dbb6a7c1 Drop unused generated_files arg from chat context
generated_files wasn't being set (anymore?). But it was being passed
around through for chat context and being saved to db.

Also reduce variables used to set mermaid diagram description
2025-08-27 13:45:09 -07:00
Debanjum
8a16f5a2af Reduce logical complexity of constructing context from chat history
- Process chat history in default order instead of processing it in
  reverse. Improve legibility of context construction for minor
  performance hit in dropping message from front of list.
- Handle multiple system messages by collating them into list
- Remove logic to drop system role for gemma-2, o1 models. Better to
  make code more readable than support old models.
2025-08-27 13:43:10 -07:00
Debanjum
1e81b51abc Support generating images with different aspect ratios
You can now specify shape of images to be generated. It can be one of
portrait, landscape or square.
2025-08-27 13:43:10 -07:00
Debanjum
5a2cae3756 Improve, simplify image generation prompts and context flow
Use seed to stabilize image change consistency across turns when
- KHOJ_LLM_SEED env var is set
- Using Image models via Replicate
  OpenAI, Google do not support image seed
2025-08-27 13:43:10 -07:00
Debanjum
0fb6020f30 Remove model type check to construct structured messages
All model types use a normalized, chatml structured message format
This check isn't used since offline model support was dropped.
2025-08-27 13:43:10 -07:00
Debanjum
386a17371d Fix identifying deepseek r1 model to process its thinking tokens 2025-08-27 13:43:04 -07:00
Debanjum
ff004d31ef Fix extracting inferred queries from chat history db
Inferred queries is stored with underscore in db but aliased with - in memory.

This conversation.messages logic was broken, so inferred queries field
of chat message history was getting ignored.

This change fixes that issue and improve previous image generation
description for better context for subsequent image generation attempts.
2025-08-25 14:19:27 -07:00
Debanjum
892e4d4077 Fix system prompt construction for gemini models
System prompt was duplicating instructions for gemini models
previously
2025-08-24 18:22:31 -07:00
Debanjum
00c5aec614 Release Khoj version 2.0.0-beta.14 2025-08-23 12:12:33 -07:00
Debanjum
b99ccbc4c3 Improve table styling, fix chat sidebar height on web app 2025-08-23 02:05:50 -07:00
Debanjum
29ae476a26 Use groq with service tier auto to fallback to flex on rate limit
Merge gpt-oss config with openai reasoning config as similar tuning.
Add pricing for gpt oss 20b model
2025-08-23 02:05:50 -07:00
Debanjum
c89c5c7b46 Reorder ai model api columns on admin panel for readability 2025-08-23 01:40:05 -07:00
Debanjum
464c1546b7 Support deepseek v3.1 via official deepseek api
The new deepseek-chat is powered by deepseek v3.1, which is a hybrid
reasoning model unlike it's predecessor, deepseek v3.
2025-08-23 01:40:05 -07:00
Debanjum
40488b3b68 Remove redundant exception for retry calls to gemini api
httpx ReadError inherits from NetworkError so not required to mention
it explicitly in gemini api call retry check
2025-08-23 00:48:10 -07:00
Debanjum
8aa9c0f534 Reduce max reasoning tokens for gemini models
A high reasoning tokens does not seem to help for standard Khoj use
cases. And hopefully reducing it may avoid repetition loops by model.
2025-08-23 00:48:10 -07:00
Debanjum
2823c84bb4 Default to gemini 2.5 model series on init and for eval 2025-08-22 20:34:38 -07:00
Debanjum
c53a70c997 Share debug logs from github eval run for debugging 2025-08-22 19:06:37 -07:00
Debanjum
e2f377c27b Render file reference as link with file preview on hover/click in web app
Overview
- Khoj references files it used in its response as markdown links.
  For example [1](file://path/to/file.txt#line=121)
- Previously these file links were just shown as raw text
- This change renders khoj's inline file references as a proper links
  and shows file content preview (around specified line if deeplink)
  on hover or click in the web app

Details
- Render inline file references as links in chat message on web app.
  Previously references like [1](file://path/to/file.txt#line=120)
  would be shown as plain text. Now they are rendered as links
- Preview file content of referenced files on click or hover.
  If reference uses a deeplink with line number, the file content
  around that line is shown on hover, click. Click allows viewing file
  preview on mobile, unlike hover. Hover is easier with mouse.
2025-08-22 18:24:27 -07:00
Debanjum
d8b7e9c8a5 Handle unset content type key when indexing knowledge base on server 2025-08-22 18:24:27 -07:00
Debanjum
3c3205bb06 Fix and improve file read, write handling in Obsidian
Fixes
- Fix to allow khoj to delete content in obsidian write mode
- Do not throw error when no edit blocks in write mode on obsidian
- Limit retries to fix invalid edit blocks in obsidian write mode

Improvements
- Only show 3 recent files as context in obsidian file read, write mode
- Persist open file access mode setting across restarts in obsidian
- Make khoj obsidian keyboard shortcuts toggle voice chat, chat history
- Do not show <SYSTEM> instructions in chat session title on obsidian

Closes #1209
2025-08-20 20:20:12 -07:00
Debanjum
48ed7afab8 Do not show <SYSTEM> instructions in chat session title on obsidian
In obsidian we have a hacky system instruction being passed in read,
write file access modes. This shouldn't be shown in chat sessions list
during view or edit. It is an internal implementation detail.
2025-08-20 20:18:27 -07:00
Debanjum
82dc7b115b Fix to allow khoj to delete content in obsidian write mode
Previous regex and replacement logic did not allow replace block to be
empty
2025-08-20 20:18:27 -07:00
Debanjum
7645cbea3b Do not throw error when no edit blocks in write mode on obsidian
Editing is an option, not a requirement in file write/edit mode.
2025-08-20 20:18:27 -07:00
Debanjum
2e6928c582 Limit retries to fix invalid edit blocks in obsidian write mode 2025-08-20 20:18:27 -07:00
Debanjum
c5e2373d73 Make khoj obsidian keyboard shortcuts toggle voice chat, chat history
Previously hitting voice chat keybinding would just start voice chat,
not end it and just open chat history and not close it.

This is unintuitive and different from the equivalent button click
behaviors.

Fix toggles voice chat on/off and shows/hides chat history when hit
Ctrl+Alt+V, Ctrl+Alt+O keybindings in khoj obsidian chat view
2025-08-20 20:18:27 -07:00
Debanjum
d8b2df4107 Only show 3 recent files as context in obsidian file read, write mode
Related #1209
2025-08-20 20:18:27 -07:00
Debanjum
eb2f0ec6bc Persist open file access mode setting across restarts in obsidian
Allows a lightweight mechanism to persist this user preference.
Improve hover text a bit for readability.

Resolves #1209
2025-08-20 20:18:27 -07:00
Debanjum
2884853c98 Make plugin object accessible to chat, find similar panes in obsidian
Allows ability to access, save settings in a cleaner way
2025-08-20 20:18:27 -07:00
Debanjum
9f6aa922a2 Improve Khoj research tools, gpt-oss support and ai api usage
Better support for GPT OSS
- Tune reasoning effort, temp, top_p for gpt-oss models
- Extract thoughts of openai style models like gpt-oss from api response

Tool use improvements
- Improve view file, code tool prompts. Format other research tool prompts
- Truncate long words in code tool stdout, stderr for context efficiency
- Use instruction instead of query as code tool argument
- Simplify view file tool. Limit viewing upto 50 lines at a time
- Make regex search tool results look more like grep results
- Update khoj personality prompts with better style, capability guide

Web UX improvements
- Wrap long words in train of thought shown on web app
- Do not overwrite charts created in previous code tool use during research
- Update web UX when server side error or hit stop + no task running

Fix AI API Usage
- Use subscriber type specific context window to generate response
- Fix max thinking budget for gemini models to generate final response
- Fix passing temp kwarg to non-streaming openai completion endpoint
- Handle unset reasoning, response chunk from openai api while streaming
- Fix using non-reasoning openai model via responses API
- Fix to calculate usage from openai api streaming completion
2025-08-20 20:06:18 -07:00
Debanjum
13d26ae8b8 Wrap long words in train of thought shown on web app 2025-08-20 19:07:28 -07:00
Debanjum
fb0347a388 Truncate long words in stdout, stderr for context efficiency
Avoid long base64 images etc. in stdout, stderr to result in context
limits being hit.
2025-08-20 19:07:28 -07:00
Debanjum
dbc3330610 Tune reasoning effort, temp, top_p for gpt-oss models 2025-08-20 19:07:28 -07:00
Debanjum
83d725d2d8 Extract thoughts of openai style models like gpt-oss from api response
They use delta.reasoning instead of delta.reasoning_content to share
model reasoning
2025-08-20 19:07:28 -07:00
Debanjum
f483a626b8 Simplify view file tool. Limit viewing upto 50 lines at a time
We were previously truncating by characters. Limiting by max lines
allows model to control line ranges they request
2025-08-20 19:07:28 -07:00
Debanjum
f5a4d106d1 Use instruction instead of query as code tool argument 2025-08-20 19:07:28 -07:00
Debanjum
c5a9c81479 Update khoj personality prompts with better style, capability guide
- Add more color to personality and communication style
- Split prompt into capabilities and style sections
- Remove directives in personality meant for older, less smart models.
- Discourage model from unnecessarily sharing code snippets in final
  response unless explicitly requested.
2025-08-20 19:07:28 -07:00
Debanjum
2c91edbb25 Improve view file, code tool prompts. Format other research tool prompts 2025-08-20 19:07:28 -07:00
Debanjum
452c794e93 Make regex search tool results look more like grep results 2025-08-20 19:07:28 -07:00
Debanjum
9a8c707f84 Do not overwrite charts created in previous code tool use during research 2025-08-20 19:07:28 -07:00
Debanjum
e0007a31bb Update web UX when server side error or hit stop + no task running
- Ack websocket interrupt even when no task running
  Otherwise chat UX isn't updated to indicate query has stopped
  processing for this edge case

- Mark chat request as not being procesed on server side error
2025-08-20 19:07:28 -07:00
Debanjum
222cc19b7f Use subscriber type specific context window to generate response 2025-08-20 19:07:28 -07:00
Debanjum
ff73d30106 Fix max thinking budget for gemini models to generate final response 2025-08-20 19:07:28 -07:00
Debanjum
34dca8e114 Fix passing temp kwarg to non-streaming openai completion endpoint
It is already being passed in model_kwargs, so not required to be
passed explicitly as well.

This code path isn't being used currently, but better to fix for
if/when it is used
2025-08-20 19:07:28 -07:00
Debanjum
8862394c15 Handle unset reasoning, response chunk from openai api while streaming 2025-08-20 19:07:28 -07:00
Debanjum
14b4d4b663 Fix using non-reasoning openai model via responses API
Pass arg to include encrypted reasoning only for reasoning openai
models. Non reasoning openai models do not except this arg
2025-08-20 19:07:28 -07:00
Debanjum
e504141c07 Fix to calculate usage from openai api streaming completion
During streaming chunk.chunk contains usage data. This regression must
have appeared while tuning openai stream processors
2025-08-20 19:07:28 -07:00
Debanjum
573c6a32e1 Fix to create chat with custom agents from obsidian (#1216)
The function createNewConversation is never called with the agentSlug
specified so its always opening a new Conversation with the default Agent
2025-08-20 19:07:16 -07:00
Debanjum
4728098cad Fix to set agent for new chat created from obsidian
- Set the agent of the current conversation in the agent dropdown when a new conversation with a non-default agent is initialized. This was unset previously.
- Pass the current selected agent in the dropdown when creating new chat
- Correctly select the `khoj-header-agent-select' element
2025-08-21 07:33:25 +05:30
Fh26697
a2a3eb8be6 Update chat_view.ts
fixed Typo
2025-08-20 17:01:47 +02:00
Fh26697
b3015f6837 Update chat_view.ts
fixed Typo
2025-08-20 16:58:08 +02:00
Fh26697
916534226a Chats are not using specified Agent
The function createNewConversation is never called with the agentSlug specified so its always opening a new Conversation with the Base Agent
2025-08-19 15:49:41 +02:00
Debanjum
fa143d45b9 Fix passing images to official openai models using the responses api 2025-08-17 16:30:43 -07:00
Debanjum
a494a766a4 Fix eval github workflow and show more logs to debug its startup 2025-08-15 16:26:37 -07:00
Debanjum
25e549d683 Show connection lost toast if disconnect while processing chat request 2025-08-15 16:04:26 -07:00
Debanjum
59bfaf9698 Fix to indicate ws disconnect on web app & save interrupted research
- A regression had stopped indicating to user that the websocket
connection had broken. Now the interrupt has some visual indication.

- Websocket disconnects from client didn't trigger the partial
research to be saved. Now we use an interrupt signal to save partial
research before closing task.
2025-08-15 16:04:26 -07:00
Debanjum
3eb8cce984 Retry if hit gemini rate limit. Return friendly message if retries fail
Although we had handling in place for retrying after gemini suggested
backoff on hitting rate limits. The actual rate limit exception was
getting caught to render friendly message, so retry wasn't actually
getting triggered.

This change allows both
- Retry on hitting 429 rate limit exceptions
- Return friendly message if rate limit triggered retry eventually fails

Related:
- Changes to retry with gemini suggested backoff time in 0f953f9
2025-08-15 16:04:25 -07:00
Debanjum
4274f58dbd Show more specific warning to llm on duplicate tool use during research 2025-08-15 16:02:32 -07:00
Debanjum
caf0b994e8 Fix handling failure to select default chat tools
Issue: chosen_io variable was accessed before initialization when
ValueError was raise.

Fix: Set chosen_io to fallback values on failure to select default
chat tools
2025-08-15 16:02:15 -07:00
Debanjum
7251b25c66 Handle null reference exceptions when rendering files context 2025-08-15 16:00:51 -07:00
Debanjum
20347e21c2 Reduce noisy indexing logs 2025-08-12 12:06:43 -07:00
Debanjum
bd82626084 Release Khoj version 2.0.0-beta.13 2025-08-11 22:29:06 -07:00
Debanjum
cbeefb7f94 Update researcher prompt to handle ambiguous queries. Clear stale text
Make researcher handle ambiguous requests better by working with
reasonable assumptions (clearly told to user in response) instead of
burdering user with clarification requests.

Fix portions of the researcher prompt that had gone stale since moving
to tool use and making researcher more task (vs q&a) oriented
2025-08-11 22:28:47 -07:00
Debanjum
0a6d87067d Fix to have researcher let the coder tool write code
Previously the researcher was passing the whole code to execute in its
queries to the tool AI instead of asking it to write the code and
limiting its query to a natural language request (with required data).

The division of responsibility should help researcher just worry about
constructing a request with all the required details instead of also
worrying about writing correct code.
2025-08-11 22:28:47 -07:00
Debanjum
0186403891 Limit retry to transient openai API errors. Return non-empty tool output 2025-08-11 21:53:21 -07:00
Debanjum
41f89cf7f3 Handle price, responses of models served via Groq
Their tool call response may not strictly follow expected response
format. Let researcher handle incorrect arguments to code tool (i.e
triggers type error)
2025-08-11 19:32:41 -07:00
Debanjum
b2d26088dc Use openai responses api to interact with official openai models
What
- Get reasoning of openai reasoning models from responses api for sho
- Improves cache hits and reasoning reuse for iterative agents like
  research mode.

This should improve speed, quality, cost and transparency of using
openai reasoning models.

More cache hits and better reasoning as reasoning blocks are included
while model is researching (reasoning intersperse with tool calls)
when using the responses api.
2025-08-09 14:03:24 -07:00
Debanjum
564adb24a7 Add support for GPT 5 model series 2025-08-09 14:03:13 -07:00
Debanjum
0e1615acc8 Fix grep files tool to work with line start, end anchors
Previously line start, end anchors would just work if the whole file
started or ended with the regex pattern rather than matching by line.

Fix it to work like a standard grep tool and match by line start, end.
2025-08-09 12:29:35 -07:00
Debanjum
a79025ee93 Limit max queries allowed per doc search tool call. Improve prompt
Reduce usage of boolean operators like "hello OR bye OR see you" which
doesn't work and reduces search quality. They're trying to stuff the
search query with multiple different queries.
2025-08-09 12:29:35 -07:00
Debanjum
a3bb7100b4 Speed up app development using a faster, modern toolchain (#1196)
## Overview
Speed up app install and development using a faster, modern development
toolchain

## Details
### Major
- Use [uv](https://docs.astral.sh/uv/) for faster server install (vs
pip)
- Use [bun](https://bun.sh/) for faster web app install (vs yarn)
- Use [ruff](https://docs.astral.sh/ruff/) for faster formatting of
server code (vs black, isort)
- Fix devcontainer builds. See if uv and bun can speed up server and
client installs

### Minor
- Format web app with prettier and server with ruff. This is most of the
file changes in this PR.
- Simplify copying web app built files in pypi workflow to make it less
flaky.
2025-08-09 12:27:20 -07:00
Debanjum
80cce7b439 Fix server, web app to reuse prebuilt deps on dev container setup 2025-08-01 23:36:13 -07:00
Debanjum
0a0b97446c Avoid `click' v8.2.2 server dependency as it breaks pypi validation
Refer pallets/click issue 3024 for details
2025-08-01 23:36:13 -07:00
Debanjum
f2bd07044e Speed up github workflows by not installing cuda server dependencies
- CI runners don't have GPUs
- Pytorch related Nvidia cuda packages are not required for testing,
  evals or pre-commit checks.
- Avoiding these massive downloads should speed up workflow run.
2025-08-01 23:35:08 -07:00
Debanjum
8ad38dfe11 Switch to Bun instead of Deno (or Yarn) for faster web app builds 2025-08-01 03:00:43 -07:00
Debanjum
b86430227c Dedupe and move dev dependencies out from web app production builds 2025-08-01 00:28:39 -07:00
Debanjum
791ebe3a97 Format web app code with prettier recommendations
Too many of these had accumulated earlier from being ignored.
Changed to make build logs less noisy
2025-08-01 00:28:39 -07:00
Debanjum
c8e07e86e4 Format server code with ruff recommendations 2025-08-01 00:28:17 -07:00
Debanjum
4a3ed9e5a4 Replace isort, black with ruff for faster linting, formatting 2025-08-01 00:01:34 -07:00
Debanjum
8700fb8937 Use UV, Deno for faster setup of development container 2025-08-01 00:01:34 -07:00
Debanjum
d2940de367 Use Deno for speed, package locks in dev setup, github workflows
It's faster than yarn and comes with standard convenience utilities
2025-08-01 00:01:34 -07:00
Debanjum
006b958071 Use UV to install server for speed, package locks in dev setup, workflows
It's much faster than pip, includes dependency locks via uv.lock and
comes with standard convenience utilities (e.g pipx, venv replacement)
2025-08-01 00:01:34 -07:00
Debanjum
e0f363d718 Use UV to manage python version, env on khoj computer
- Use khoj username on khoj's computer
- Uv is much faster for builds
2025-07-31 18:31:24 -07:00
Debanjum
0387b86a27 Use portable comparator to get flags used to call dev_setup.sh 2025-07-31 18:31:24 -07:00
Debanjum
c6670e815a Drop Server Side Indexer, Native Offline Chat, Old Migration Scripts (#1212)
### Overview
Make server leaner to increase development speed. 
Remove old indexing code and the native offline chat which was hard to
maintain.

- The native offline chat module was written when the local ai model api
ecosystem wasn't mature. Now it is. Reuse that.
- Offline chat requires GPU for usable speeds. Decoupling offline chat
from Khoj server is the recommended way to go for practical inference
speeds (e.g Ollama on machine, Khoj in docker etc.)

### Details
- Drop old code to index files on server filesystem. Clean cli, init
paths.
- Drop native offline chat support with llama-cpp-python. 
  Use established local ai APIs like Llama.cpp Server, Ollama, vLLM etc.
- Drop old pre 1.0 khoj config migration scripts
- Update test setup to index test data after old indexing code removed.
2025-07-31 20:26:08 -05:00
Debanjum
892d57314e Update test setup to index test data after old indexing code removed
- Delete tests testing deprecated server side indexing flows
- Delete `Local(Plaintext|Org|Markdown|Pdf)Config' methods, files and
  references in tests
- Index test data via new helper method, `get_index_files'
  - It is modelled after the old `get_org_files' variants in main app
  - It passes the test data in required format to `configure_content'
    Allows maintaining the more realistic tests from before while
    using new indexing mechanism (rather than the deprecated server
    side indexing mechanism
2025-07-31 18:25:32 -07:00
Debanjum
d9d24dd638 Drop old code to sync files on server filesystem. Clean cli, init paths
This stale code was originally used to index files on server file
system directly by server. We currently push files to sync via API.

Server side syncing of remote content like Github and Notion is still
supported. But old, unused code for server side sync of files on
server fs is being cleaned out.

New --log-file cli args allows specifying where khoj server should
store logs on fs. This replaces the --config-file cli arg that was
only being used as a proxy for deciding where to store the log file.

- TODO
  - Tests are broken. They were relying on the server side content
    syncing for test setup
2025-07-31 18:25:32 -07:00
Debanjum
b1f2737c9a Drop native offline chat support with llama-cpp-python
It is recommended to chat with open-source models by running an
open-source server like Ollama, Llama.cpp on your GPU powered machine
or use a commercial provider of open-source models like DeepInfra or
OpenRouter.

These chat model serving options provide a mature Openai compatible
API that already works with Khoj.

Directly using offline chat models only worked reasonably with pip
install on a machine with GPU. Docker setup of khoj had trouble with
accessing GPU. And without GPU access offline chat is too slow.

Deprecating support for an offline chat provider directly from within
Khoj will reduce code complexity and increase developement velocity.
Offline models are subsumed to use existing Openai ai model provider.
2025-07-31 18:25:32 -07:00
Debanjum
3f8cc71aca Drop old pre 1.0 khoj config migration scripts
These were used when khoj was configured using khoj.yml file
2025-07-31 18:25:32 -07:00
Debanjum
9096f628d0 Release Khoj version 2.0.0-beta.12 2025-07-31 18:13:17 -07:00
Debanjum
a6923fac76 Improve description of query arg to semantic, web search tool
Clarify that the tool AI will perform a maximum of X sub-queries for
each query passed to it by the manager AI.

Avoids the manager AI from trying to directly pass a list of queries
to the search tool AI. It should just pass just a single query.
2025-07-31 18:00:46 -07:00
Debanjum
2e13c9a007 Buffer thought chunks on server side for more performant ws streaming
Send larger thought chunks to improve streaming efficiency and
reduce rendering load on web client.

This rendering load was most evident when using high throughput
models or low compute clients.

The server side message buffering should result in fewer re-renders,
faster streaming and lower compute load on client.

Related commit to buffer message content in fc99f8b37
2025-07-31 18:00:46 -07:00
Debanjum
fba4ad27f7 Extract thought stream from reasoning_content of openai model providers
Grok 3 mini at least sends thoughts in reasoning_content field of
streamed chunk delta. Extract model thoughts from that when available.
2025-07-31 18:00:46 -07:00
Debanjum
b335f8cf79 Support grok 4 reasoning model 2025-07-31 18:00:46 -07:00
Debanjum
c0db9e4fca Use better, standard default temp, top_p for openai model providers 2025-07-31 18:00:46 -07:00
Debanjum
7ab24d875d Release Khoj version 2.0.0-beta.11 2025-07-31 10:25:42 -07:00
Debanjum
6290d744ea Make code tool write safe code to run in sandbox
- Ask both manager and code gen AI to not run or write
  unsafe code for some safety improvement (over code exec in sandbox).
- Disallow custom agent prompts instructing unsafe code gen
2025-07-31 00:11:50 -07:00
Debanjum
0f953f9ec8 Use Gemini suggested retry backoff if set. Improve gemini error handling 2025-07-30 18:16:16 -07:00
Debanjum
bbc14951b4 Redirect to a better error page on server error 2025-07-30 18:08:07 -07:00
Debanjum
6caa6f4008 Make async call to get agent files from async agent/conversation API
This should avoid the sync_to_async errors thrown by django when
calling the /api/agent/conversation API endpoint
2025-07-30 17:37:54 -07:00
Debanjum
b82d4fe68f Resolve Pydantic deprecation warnings (#1211)
## PR Summary
This PR resolves the deprecation warnings of the Pydantic library, which
you can find in the [CI
logs](https://github.com/khoj-ai/khoj/actions/runs/16528997676/job/46749452047#step:9:142):
```python
PydanticDeprecatedSince20: The `copy` method is deprecated; use `model_copy` instead. See the docstring of `BaseModel.copy` for details about how to handle `include` and `exclude`. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.11/migration/
```
2025-07-25 19:50:57 -05:00
Emmanuel Ferdman
655a1b38f2 Resolve Pydantic deprecation warnings
Signed-off-by: Emmanuel Ferdman <emmanuelferdman@gmail.com>
2025-07-25 16:55:00 -07:00
Debanjum
f5d12b7546 Bump desktop app and documentation dependencies 2025-07-25 13:37:45 -05:00
Debanjum
f8924f2521 Avoid duplicate chat turn save if chat cancelled during final response
Save to conversation in normal flow should only be done if
interrupt wasn't triggered.

Saving conversations on interrupt is handled completely by the
disconnect monitor since the improvements to interrupt.

This abort is handled correctly for steps before final response. But
not if interrupt occurs while final response is being sent. This
changes checks for cancellation after final response send attempt and
avoids duplicate chat turn save.
2025-07-25 13:28:13 -05:00
Debanjum
bd9f091a71 Show thoughts of more llm models served via openai compatible api
- Extract llm thoughts from more openai compatible ai api providers
  like llama.cpp server vllm and litellm.
  - Try structured thought extraction by default
  - Try in-stream thought extraction for specific model families like
    qwen and deepseek.
- Show thoughts with tool use. For intermediate steps like research
  mode from openai compatible models

Some consensus on thought in model response is being reached with
using deepseek style thoughts in structured response (via
"reasoning_content" field)  or qwen style thoughts in main
response (i.e <think></think> tags).

Default to try deepseek style structured thought extraction. So the
previous default stream processor isn't required.
2025-07-25 13:28:13 -05:00
Debanjum
624d6227ca Expand to enable deep think for more qwen style models like smollm3 2025-07-25 13:28:13 -05:00
Debanjum
c401bb9591 Stricty enforce tool call schema for llm served via openai compat api
This is required by llama.cpp server and is recommended in general for
openai compatible models
2025-07-25 13:28:13 -05:00
Debanjum
03c4f614dd Handle tool call requests with openai completion in non stream mode 2025-07-25 13:28:13 -05:00
Debanjum
70cfaf72e9 Only send start llm response chat event once, after thoughts streamed
A previous regression resulted in the start llm response event being
sent with every (non-thought) message chunk. It should only be sent
once after thoughts and before first normal message chunk is streamed.

Regression probably introduced with changes to stream thoughts.

This should fix the chat streaming latency logs.
2025-07-25 13:28:13 -05:00
Debanjum
15c6118142 Store event delimiter in chat event enum for reuse 2025-07-25 13:28:13 -05:00
Debanjum
fc99f8b37e Buffer message chunks on server side for more performant ws streaming
Send larger message chunks to improve streaming efficiency and
reduce rendering load on web client.

This rendering load was most evident when using high throughput
models, low compute clients and message with images. As message
content was rerendered on every token sent to the web app.

The server side message buffering should result in fewer re-renders
and lower compute load on client.
2025-07-25 13:28:13 -05:00
Debanjum
bf9a9c7283 Disable per user websocket connection limits in anon or debug mode
This rate limiting is only relevant in production scenarios.
2025-07-25 12:19:15 -05:00
Debanjum
48e21d9f0f Release Khoj version 2.0.0-beta.10 2025-07-19 21:32:14 -05:00
Debanjum
e57acf617a Convert websocket rate limiter to async method
Fixes calling websocket rate limiter from async chat_ws method.

Not sure why the issue did not trigger in local setups. Maybe has to
do with gunicorn vs uvicorn / multi-workers setup in prod vs local.
2025-07-19 21:15:51 -05:00
Debanjum
76a1b0b686 Release Khoj version 2.0.0-beta.9 2025-07-19 20:20:50 -05:00
Debanjum
43d7e65a49 Limit chat message interrupt queue size to limit performance impact 2025-07-19 20:16:53 -05:00
Debanjum
749160e38d Validate websocket origin before establishing connection 2025-07-19 20:07:21 -05:00
Debanjum
69a7d332fc Limit number of new websocket connections allowed per user 2025-07-19 20:04:36 -05:00
Debanjum
76ddf8645c Improve rate limit and interrupt messages for user, admin 2025-07-19 19:13:51 -05:00
Debanjum
de7668daec Add websocket chat api to ease bi-directional communication (#1207)
- Add a websocket api endpoint for chat. Reuse most of the existing chat
logic.
- Communicate from web app using the websocket chat api endpoint.
- Pass interrupt messages using websocket to guide research, operator
trajectory
Previously we were using the abort and send new POST /api/chat
mechanism.
This didn't scale well to multi-worker setups as a different worker
could pick up the new interrupt message request.
  Using websocket to send messages in the middle of long running tasks
  should work more naturally.
2025-07-17 18:06:43 -07:00
Debanjum
b90e2367d5 Fix interrupt UX and research when using websocket via web app 2025-07-17 17:09:21 -07:00
Debanjum
0ecd5f497d Show more informative title for semantic search train of thought 2025-07-17 17:09:21 -07:00
Debanjum
7b7b1830b7 Make callers only share new messages to append to chat logs
- Chat history is retrieved and updated with new messages just before
  write. This is to reduce chance of message loss due to conflicting
  writes making last to save to conversation win conflict.
- This was problematic artifact of old code. Removing it should reduce
  conflict surface area.
- Interrupts and live chat could hit this issue due to different reasons
2025-07-17 17:09:18 -07:00
Debanjum
eaed0c839e Use websocket chat api endpoint to communicate from web app
- Use websocket library to handle setup, reconnection from web app
Use react-use-websocket library to handle websocket connection and
reconnection logic. Previously connection wasn't re-established on
disconnects.

- Send interrupt messages with ws to update research, operator trajectory

Previously we were using the abort and send new POST /api/chat
mechanism.

But now we can use the websocket's bi-directional messaging capability
to send users messages in the middle of a research, operator run.

This change should
1. Allow for a faster, more interactive interruption to shift the
research direction without breaking the conversation flow. As
previously we were using the DB to communicate interrupts across
workers, this would take time and feel sluggish on the UX.

2. Be a more robust interrupt mechanism that'll work in multi worker
setups. As same worker is interacted with to send interrupt messages
instead of potentially new worker receiving the POST /api/chat with
the interrupt user message.

On the server we're using an asyncio Queue to pass messages down from
websocket api to researcher via event generator. This can be extended
to pass to other iterative agents like operator.
2025-07-17 17:06:55 -07:00
Debanjum
9f0eff6541 Handle passing interrupt messages from api to chat actors on server 2025-07-17 17:06:55 -07:00
Debanjum
38dd85c91f Add websocket chat api endpoint to ease bi-directional communication 2025-07-17 17:06:55 -07:00
Debanjum
99ed796c00 Release Khoj version 2.0.0-beta.8 2025-07-15 16:42:44 -07:00
Debanjum
0a05a5709e Use agent chat model to generate code instead of default chat model
This is consistent with chat model preference order for other tools
2025-07-15 16:22:29 -07:00
Debanjum
238bd66c42 Fix to map user tool names to equivalent tool sets for research mode
Fix using research tool names instead of slash command tool names
(exposed to user) in research mode conversation history construction.

Map agent input tools to relevant research tools. Previously
using agents with a limited set of tools in research mode reduces
tools available to agent in research mode.

Fix checks to skip tools if not configured.
2025-07-15 16:22:29 -07:00
Debanjum
76ed97d066 Set friendly name for auto loaded chat models during first run
The chat model friendly name field was introduced in a8c47a70f. But
we weren't setting the friendly name for ollama models, which get
automatically loaded on first run.

This broke setting chat model options, server admin settings and
creating new chat pages (at least) as they display the chat model's
friendly name.

This change ensures the friendly name for auto loaded chat models is
set to resolve these issues. We also add a null ref check to web app
model selector as an additional safeguard to prevent new chat page
crash due to missing friendly name going forward.

Resolves #1208
2025-07-15 14:27:04 -07:00
Debanjum
0a06f5b41a Release Khoj version 2.0.0-beta.7 2025-07-11 00:04:56 -07:00
Debanjum
d42176fa7e Drop tool call, result without tool id on call to Anthropic, Openai APIs 2025-07-11 00:00:05 -07:00
Debanjum
d27aac7f13 Suppress non-actionable pdf indexing warning from logs 2025-07-11 00:00:05 -07:00
Debanjum
05176cd62b Log dropping messages with invalid content as warnings, not errors
They are expected when conversation got interrupted.
2025-07-11 00:00:05 -07:00
Debanjum
b2952236c4 Log conversation id to help troubleshoot errors faster 2025-07-10 23:56:42 -07:00
Debanjum
25db59e49c Fix to return openai formatted messages in the correct order
We'd reversed the formatting of openai messages to drop invalid
messages without affecting the other messages being appended . But we
need to reverse the final formatted list to return in the right order.
2025-07-10 23:56:22 -07:00
Debanjum
c8ec29551f Drop invalid messages in reverse order to continue interrupted chats
Previously
- message with invalid content were getting dropped in normal order
  which would change the item index being iterated for gemini and
  anthropic models
- messages with empty content weren't getting dropped for openai
  compatible api models. While openai api is resilient to this, it's
  better to drop these invalid messages as other openai compatible
  APIs may not handle this.

We see messages with empty or no content when chat gets interrupted
due to disconnections, interrupt messages or explicit aborts by user.

This changes should now drop invalid messages and not mess formatting
of the other messages in a conversation. It should allow continuing
interrupted conversations with any ai model.
2025-07-10 22:39:52 -07:00
Debanjum
f1a3ddf2ca Release Khoj version 2.0.0-beta.6 2025-07-10 13:41:06 -07:00
Debanjum
7b637d3432 Use document style ux when print conversations to pdf
Inspired by my previous turnstyle ux explorations.

But basically user message becomes section title and khoj message
becomes section body with the timestamp being used a section title,
body divider.
2025-07-10 13:27:04 -07:00
Debanjum
c28e90f388 Revert to use standard 1.0 temperature for gemini models
Using temp of 1.2 didn't help eliminate the repetition loops the
gemini models go into sometimes.
2025-07-09 18:22:05 -07:00
Debanjum
b763dbfb2b Timeout web search and webpage read requests to providers 2025-07-09 18:12:07 -07:00
Debanjum
1988a8d023 Fix to delete agent by slug in DB via API 2025-07-09 18:12:07 -07:00
Debanjum
69336565b1 Do not show research mode tools as slash commands options on clients
These are tools meant for the research agent, not for users to use.
2025-07-09 18:12:07 -07:00
Debanjum
cc6da4c440 Drop unsupported additionalProperties field from gemini tool definitions 2025-07-09 18:06:40 -07:00
Debanjum
3141035f48 Handle unexpected chunks streamed from Openai (compatible) APIs 2025-07-09 17:54:42 -07:00
Debanjum
a601cca79b Handle cases where no organic online search results found
Previous organic results enumerator only handled the scenario where
organic key wasn't present in online search results.

It did not handle the case where there were no organic online search
results.
2025-07-09 00:25:10 -07:00
Debanjum
f2b86aa7c8 Release Khoj version 2.0.0-beta.5 2025-07-08 23:45:29 -07:00
Debanjum
0f0cfba624 Ignore vscode settings.json from pre-commit json check
Vscode settings.json follows jsonc (json with comments) format
2025-07-08 23:27:10 -07:00
Debanjum
f0513cbbb1 Fix to run new automation api tests in ci 2025-07-08 23:27:09 -07:00
Debanjum
c144aa9c90 Handle automation calling url of both url and string type
Calling url can be of url type in production but locally it is of
string type. Unclear why. But this change should mitigate the issue
for now.
2025-07-08 21:10:54 -07:00
Debanjum
fad6a638bd Release Khoj version 2.0.0-beta.4 2025-07-08 19:34:52 -07:00
Debanjum
8d9e75f580 Fix automation url parsing, response handling. Test automations api
- Methods calling send_message_to_model_wrapper_sync handn't been
  update to handle the function returning new ResponseWithThought
- Store, load request.url to DB as, from string to avoid serialization
  issues
2025-07-08 17:40:19 -07:00
Debanjum
254207b010 Make chats print friendly to share via print to PDF etc. from browser
Add print specific styling to hide side panels and chat input footers.
Add heading with khoj logo, conversation title, agent and date.
2025-07-08 12:20:19 -07:00
Debanjum
8fb38d9e1e Make vscode pylint only analyse khoj server directory for efficiency 2025-07-08 12:20:19 -07:00
Debanjum
9a215141f0 Release Khoj version 2.0.0-beta.3 2025-07-06 12:50:27 -07:00
Debanjum
da9a78e79b Make URI field optional for now to handle previously saved documents
For files not synced after the previous release, context uri is unset.
This results in failure to save chat messages that retrieve documents
as the uri field cannot be unset so pre save validation fails.

We'd use a db migration to handle this but this is a quick mitigation
for now.
2025-07-06 12:49:07 -07:00
Debanjum
bc6bbb4c96 Release Khoj version 2.0.0-beta.2 2025-07-06 12:10:32 -07:00
Debanjum
4c33d1a526 Fallback to file based URI when document context URI is unset
For files not synced after the previous release, context uri is unset.
This results in failure to save chat messages that retrieve documents
as the uri field cannot be unset so pre save validation fails
2025-07-06 12:06:02 -07:00
Debanjum
9dc146bb08 Bump rapidocr dependency version 2025-07-06 12:06:02 -07:00
Debanjum
b27ba1d24b Early init chat_history in chat api to avoid unbound in edge case
Monitor disconnect can trigger earlier than chat history is
initialized. This can cause unbound chat history exception.
2025-07-06 12:06:02 -07:00
Debanjum
8cd2a1a961 Release Khoj version 2.0.0-beta.1 2025-07-06 10:39:56 -07:00
Debanjum
6bda8dc20b Fix file upload size limit client test after max upload bump 2025-07-06 10:24:44 -07:00
Debanjum
531ae80212 Allow 50mb knowledge base size on free tier 2025-07-06 09:56:29 -07:00
Debanjum
58f44ad43b Make release/1.x a privileged branch to run workflows, create releases
It'll work similar to the master branch but with pre-1x and latest-1x
tagged series of docker images.

This should ease deployment changes from 1.x vs 2.x series
2025-07-06 09:28:16 -07:00
Debanjum
2daf396cbb Bump pre-release version in SemVer schema via bump_version script 2025-07-06 09:28:16 -07:00
Debanjum
afa810e552 Retry api calls to gemini on network read error 2025-07-06 09:27:54 -07:00
Debanjum
2ec39d295d Add Deeplinks to Improve Context for Document Retrieval (#1206)
## Overview
Show deep link URI and raw document context to provide deeper, richer
context to Khoj. This should allow it better combine semantic search
with other new document retrieval tools like line range based file
viewer and regex tools added in #1205

## Details
- Attach line number based deeplinks to each indexed document entry
Document URI follows URL fragment based schema of form
`file:///path/to/file.txt#line=123`
- Show raw indexed document entries with deep links to LLM when it uses
the semantic search tool
- Reduce structural changes to raw org-mode entries for easier deep
linking.
2025-07-03 20:05:04 -07:00
Debanjum
5010623a0a Deep link to markdown entries by line number in uri
Use url fragment schema for deep link URIs, borrowing from URL/PDF
schemas. E.g file:///path/to/file.txt#line=<line_no>&#page=<page_no>

Compute line number during (recursive) markdown entry chunking.

Test line number in URI maps to line number of chunk in actual md file.

This deeplink URI with line number is passed to llm as context to
better combine with line range based view file tool.

Grep tool already passed matching line number. This change passes
line number in URIs of markdown entries matched by the semantic search
tool.
2025-07-03 19:27:57 -07:00
Debanjum
dcfa4288c4 Deep link to org-mode entries. Deep link by line number in uri
Use url fragment schema for deep link URIs, borrowing from URL/PDF
schemas. E.g file:///path/to/file.txt#line=<line_no>&#page=<page_no>

Compute line number during (recursive) org-mode entry chunking.

Thoroughly test line number in URI maps to line number of chunk in
actual org mode file.

This deeplink URI with line number is passed to llm as context to
better combine with line range based view file tool.

Grep tool already passed matching line number. This change passes
line number in URIs of org entries matched by the semantic search tool
2025-07-03 17:38:34 -07:00
Debanjum
e90ab5341a Add context uri field to deeplink line number in original doc 2025-07-03 17:38:34 -07:00
Debanjum
820b4523fd Show raw rather than compiled entry to llm and users
Only embedding models see, operate on compiled text.

LLMs should see raw entry to improve combining it with other document
traversal tools for better regex and line matching.

Users see raw entry for better matching with their actual notes.
2025-07-03 17:38:34 -07:00
Debanjum
5c4d41d300 Reduce structural changes to indexed raw org mode entries
Reduce structural changes to raw entry allows better deep-linking and
re-annotation. Currently done via line number in new uri field.

Only add properties drawer to raw entry if entry has properties
Previously line and source properties were inserted into raw entries.
This isn't done anymore. Line, source are deprecated for use in khoj.el.
2025-07-03 17:38:31 -07:00
sabaimran
870d9d851a Only handle Stripe webhooks meant for the KHOJ_CLOUD product 2025-07-03 17:02:49 -07:00
Debanjum
fe44cd3c59 Upgrade Retrieval from KB in Research Mode. Use Function Calling for Tool Use (#1205)
## Why
Move to function calling paradigm to give models tool call -> tool
result in formats they're fine-tuned to understand. Previously we were
giving them results in our specific format (as function calling paradigm
wasn't well-established yet).

And improve prompt cache hits by caching tool definitions.

This is a **breaking change**. AI Models and APIs that do not support
function calling will not work with Khoj in research mode. Function
calling is supported by:
- Standard commercial AI Models and APIs like Anthropic, Gemini, OpenAI,
OpenRouter
- Standard open-source AI APIs like llama.cpp server, Ollama
- Standard open source models like Qwen, DeepSeek, Gemma, Llama, Mistral

## What
### Use Function Calling for Tool Use
- Add Function Calling support to Anthropic, Gemini, OpenAI AI Model
APIs
- Move Existing Research Mode Tools to Use Function Calling

### Get More Comprehensive Results from your Knowledge Base (KB)
- Give Research Agent better Document Retrieval Tools
  - Add grep files tool to enable researcher to find documents by regex
  - Add list files tool to enable researcher to find documents by path
  - Add file viewer tool to enable researcher to read documents

### Miscellaneous
- Improve Research Prompt, Truncation, Retry and Caching
- Show reasoning model thoughts in Khoj train of thought for
intermediate steps as well
2025-07-03 00:14:07 -07:00
Debanjum
f343a92b1d Give research tools better, consistent names for balanced usage 2025-07-02 23:32:44 -07:00
Debanjum
aa081913bf Improve truncation with tool use and Anthropic caching
- Cache last anthropic message. Given research mode now uses function
  calling paradigm and not the old research mode structure.
- Cache tool definitions passed to anthropic models
- Stop dropping first message if by assistant as seems like Anthropic
  API doesn't complain about it any more.

- Drop tool result when tool call is truncated as invalid state
- Do not truncate tool use message content, just drop the whole tool
  use message.

  AI model APIs need tool use assistant message content in specific
  form (e.g with thinking etc.). So dropping content items breaks
  expected tool use message content format.

Handle tool use scenarios where iteration query isn't set for retry
2025-07-02 23:32:44 -07:00
Debanjum
786b06bb3f Handle failed llm calls, message idempotency to improve retry success
- Deepcopy messages before formatting message for Anthropic to allow
  idempotency so retry on failure behaves as expected
- Handle failed calls to pick next tools to pass failure warning and
  continue next research iteration. Previously if API call to pick
  next failed, the research run would crash
- Add null response check for when Gemini models fail to respond
2025-07-02 23:32:30 -07:00
Debanjum
30878a2fed Show thoughts and text response in thoughts on anthropic tool use
Previously if anthropic models were using tools, the models text
response accompanying the tool use wouldn't be shown as they were
overwritten in aggregated response with the tool call json.

This changes appends the text response to the thoughts portion on tool
use to still show model's thinking. Thinking and text response are
delineated by italics vs normal text for such cases.
2025-07-02 20:48:24 -07:00
Debanjum
c2ab75efef Track, reuse raw model response for multi-turn conversations
This should avoid the need to reformat the Khoj standardized tool call
for cache hits and satisfying ai model api requirements.

Previously multi-turn tool use calls to anthropic reasoning models
would fail as needed their thoughts to be passed back. Other AI model
providers can have other requirements.

Passing back the raw response as is should satisfy the default case.

Tracking raw response should make it easy to apply any formatting
required before sending previous response back, if any ai model
provider requires that.

Details
---
- Raw response content is passed back in ResponseWithThoughts.
- Research iteration stores this and puts it into model response
  ChatMessageModel when constructing iteration history when it is
  present.
  Fallback to using parsed tool call when raw response isn't present.
- No need to format tool call messages for anthropic models as we're
  passing the raw response as is.
2025-07-02 20:48:24 -07:00
Debanjum
7cd496ac19 Frame research prompt as accomplish task instead of answer question
Researcher is expanding into accomplish task behavior, especially with
tool use from the previous collect information to answer user query
behavior.

Update the researcher's system prompt to reflect the new objective better.
Encourage model to not stop working on task until achieve objective
2025-07-02 20:48:24 -07:00
Debanjum
4e67ba4d6c Support seeing lines around regex match with grep files tool
Let research agent see lines surrounding regex matched lines when
using grep files tool to improve document retrieval quality
2025-07-02 20:48:24 -07:00
Debanjum
d81fb08366 Use case insensitive regex matching with grep files tool 2025-07-02 20:48:24 -07:00
Debanjum
9c38326608 Add grep files tool to enable researcher to find documents by regex
Earlier khoj could technically only answer questions existential
questions, i.e question that would terminate once any relevant note to
answer that question was found.

This change enables khoj to answer universal questions, i.e questions
that require searching through all notes or finding all instances.

It enables more thorough retrieval from user's knowledge base by
combining semantic search, regex search, view and list files tools.

For more development details including motivation, see live coding
session 1.1 at https://www.youtube.com/live/-2s_qi4hd2k
2025-07-02 20:48:24 -07:00
Debanjum
59f5648dbd Add list files tool to enable researcher to find documents by path
Allow getting a map of user's knowledge base under specified path.

This enables more thorough retrieval from user's knowledge base by
combining search, view and list files tools.
2025-07-02 20:48:24 -07:00
Debanjum
2f9f608cff Add file viewer tool to enable researcher to read documents
Allow reading whole file contents or content in specified line range
in user's knowledge base. This allows for more deterministic
traversal.
2025-07-02 20:48:24 -07:00
Debanjum
721c55a37b Rename ResponseWithThought response field to text for better naming 2025-07-02 20:48:24 -07:00
Debanjum
490f0a435d Pass research tools directly with their varied args for flexibility
Why
---
Previously researcher had a uniform response schema to pick next tool,
scratchpad, query and tool. This didn't allow choosing different
arguments for the different tools being called. And the tool call,
result format passed by khoj was custom and static across all LLMs.

Passing the tools and their schemas directly to llm when picking next
tool allows passing multiple, tool specific arguments for llm to
select. For example, model can choose webpage urls to read or image
gen aspect ratio (apart from tool query) to pass to the specific tool.

Using the LLM tool calling paradigm allows model to see tool call,
tool result in a format that it understands best.

Using standard tool calling paradigm also allows for incorporating
community builts tools more easily via MCP servers, clients tools,
native llm api tools etc.

What
---
- Return ResponseWithThought from completion_with_backoff ai model
  provider methods
- Show reasoning model thoughts in research mode train of thought.
  For non-reasoning models do not show researcher train of thought.
  As non-reasoning models don't (by default) think before selecing
  tool. Showing tool call is lame and resembles tool's action shown in
  next step.

- Store tool calls in standardized format.
- Specify tool schemas in tool for research llm definitions as well.
- Transform tool calls, tool results to standardized form for use
  within khoj. Manage the following tool call, result transformations:
  - Model provider tool_call -> standardized tool call
  - Standardized tool call, result -> model specific tool call, result

- Make researcher choose webpages urls to read as well for the webpage
  tool. Previously it would just decide the query but let the webpage
  reader infer the query url(s). But researcher has better context on
  which webpages it wants to have read to answer their query.

  This should eliminate the webpage reader deciding urls to read step
  and speed up webpage read tool use.

Handle unset response thoughts. Useful when retry on failed request

Previously resulted in unbound local variable response_thoughts error
2025-07-02 20:48:23 -07:00
Debanjum
80522e370e Make researcher pick next tool using model function calling feature
The pick next tool requests next tool to call to model in function
calling / tool use format.
2025-07-02 19:10:02 -07:00
Debanjum
b888d5e65e Add function calling support to Anthropic, Gemini and OpenAI models
Previously these models could use response schema but not tools use
capabilities provided by these AI model APIs.

This change allows chat actors to use the function calling feature to
specify which tools the LLM by these providers can call.

This should help simplify tool definition and structure context in
forms that these LLMs natively understand.
(i.e in tool_call - tool_result ~chatml format).
2025-07-02 19:10:02 -07:00
223 changed files with 23042 additions and 16390 deletions

View File

@@ -1,40 +1,49 @@
ARG PYTHON_VERSION=3.10
ARG PYTHON_VERSION=3.12
FROM mcr.microsoft.com/devcontainers/python:${PYTHON_VERSION}
# Install Node.js and Yarn
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt-get install -y nodejs
# Install UV and Bun
RUN curl -fsSL https://bun.sh/install | bash && mv /root/.bun/bin/bun /usr/local/bin/bun
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN uv python pin $PYTHON_VERSION
# create python virtual environment
RUN uv venv /opt/venv --python $PYTHON_VERSION --seed
# Add venv to PATH for subsequent RUN commands and for the container environment
ENV PATH="/opt/venv/bin:$PATH"
# Tell pip, uv to use this virtual environment
ENV VIRTUAL_ENV="/opt/venv"
ENV UV_PROJECT_ENVIRONMENT="/opt/venv"
# Setup working directory
WORKDIR /workspace
WORKDIR /workspaces/khoj
# --- Python Server App Dependencies ---
# Create Python virtual environment
RUN python3 -m venv /opt/venv
# Add venv to PATH for subsequent RUN commands and for the container environment
ENV PATH="/opt/venv/bin:${PATH}"
# Copy files required for Python dependency installation.
COPY pyproject.toml README.md ./
# Setup python environment
# Use the pre-built llama-cpp-python, torch cpu wheel
ENV PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/cpu https://abetlen.github.io/llama-cpp-python/whl/cpu" \
# Use the pre-built torch cpu wheel
ENV UV_INDEX="https://download.pytorch.org/whl/cpu" \
UV_INDEX_STRATEGY="unsafe-best-match" \
# Avoid downloading unused cuda specific python packages
CUDA_VISIBLE_DEVICES="" \
# Use static version to build app without git dependency
VERSION=0.0.0
VERSION=0.0.0 \
# Use embedded db
USE_EMBEDDED_DB="True" \
PGSERVER_DATA_DIR="/opt/khoj_db"
# Install Python dependencies from pyproject.toml in editable mode
RUN sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.toml && \
pip install --no-cache-dir ".[dev]"
uv sync --all-extras && \
# Save the lock file generated with correct Linux platform wheels
cp uv.lock /opt/uv.lock.linux && \
chown -R vscode:vscode /opt/venv
# --- Web App Dependencies ---
# Copy web app manifest files
COPY src/interface/web/package.json src/interface/web/yarn.lock /tmp/web/
COPY src/interface/web/package.json src/interface/web/bun.lock /opt/khoj_web/
# Install web app dependencies
# note: yarn will be available from the "features" in devcontainer.json
RUN yarn install --cwd /tmp/web --cache-folder /opt/yarn-cache
RUN cd /opt/khoj_web && bun install && chown -R vscode:vscode .
# The .venv and node_modules are now populated in the image.
# The rest of the source code will be mounted by VS Code from your local checkout,

View File

@@ -4,7 +4,7 @@
"dockerfile": "Dockerfile",
"context": "..", // Build context is the project root
"args": {
"PYTHON_VERSION": "3.10"
"PYTHON_VERSION": "3.12"
}
},
"forwardPorts": [
@@ -53,11 +53,6 @@
"postCreateCommand": "scripts/dev_setup.sh --devcontainer",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/node:1": {
"version": "lts",
"installYarnUsingApt": false,
"nodeGypDependencies": true
}
},
"remoteUser": "vscode"
}

View File

@@ -6,12 +6,14 @@ on:
push:
branches:
- 'master'
- 'release/1.x'
paths:
- src/interface/emacs/*.el
- .github/workflows/build_khoj_el.yml
pull_request:
branches:
- 'master'
- 'release/1.x'
paths:
- src/interface/emacs/*.el
- .github/workflows/build_khoj_el.yml

View File

@@ -6,6 +6,7 @@ on:
- "*"
branches:
- 'master'
- 'release/1.x'
paths:
- src/interface/desktop/**
- .github/workflows/desktop.yml

View File

@@ -6,6 +6,7 @@ on:
- "*"
branches:
- master
- release/1.x
paths:
- src/khoj/**
- src/interface/web/**
@@ -37,8 +38,8 @@ env:
# Tag Image with tag name on release
# else with user specified tag (default 'dev') if triggered via workflow
# else with run_id if triggered via a pull request
# else with 'pre' (if push to master)
DOCKER_IMAGE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || github.event_name == 'workflow_dispatch' && github.event.inputs.tag || 'pre' }}
# else with 'pre' (if push to master) or 'pre-1x' (if push to release/1.x)
DOCKER_IMAGE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name == 'release/1.x' && 'pre-1x' || 'pre' }}
jobs:
build:
@@ -153,18 +154,32 @@ jobs:
- name: Create and Push Local Manifest
if: github.event.inputs.khoj == 'true' || github.event_name == 'push'
run: |
# Only put "latest.*" tag on stable releases (i.e 1.x, 2.x+)
if [[ "${{ env.DOCKER_IMAGE_TAG }}" =~ ^[1-9]\.[0-9]+\.[0-9]+$ ]]; then
LATEST_TAG="latest"
else
LATEST_TAG="${{ env.DOCKER_IMAGE_TAG }}"
fi
docker buildx imagetools create \
-t ghcr.io/${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }} \
-t ghcr.io/${{ github.repository }}:${{ github.ref_type == 'tag' && 'latest' || env.DOCKER_IMAGE_TAG }} \
-t ghcr.io/${{ github.repository }}:${LATEST_TAG} \
ghcr.io/${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }}-amd64 \
ghcr.io/${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }}-arm64
- name: Create and Push Cloud Manifest
if: github.event.inputs.khoj-cloud == 'true' || github.event_name == 'push'
run: |
# Only put "latest.*" tag on stable releases (i.e 1.x, 2.x+)
if [[ "${{ env.DOCKER_IMAGE_TAG }}" =~ ^[1-9]\.[0-9]+\.[0-9]+$ ]]; then
LATEST_TAG="latest"
else
LATEST_TAG="${{ env.DOCKER_IMAGE_TAG }}"
fi
docker buildx imagetools create \
-t ghcr.io/${{ github.repository }}-cloud:${{ env.DOCKER_IMAGE_TAG }} \
-t ghcr.io/${{ github.repository }}-cloud:${{ github.ref_type == 'tag' && 'latest' || env.DOCKER_IMAGE_TAG }} \
-t ghcr.io/${{ github.repository }}-cloud:${LATEST_TAG} \
ghcr.io/${{ github.repository }}-cloud:${{ env.DOCKER_IMAGE_TAG }}-amd64 \
ghcr.io/${{ github.repository }}-cloud:${{ env.DOCKER_IMAGE_TAG }}-arm64

View File

@@ -20,9 +20,9 @@ jobs:
uses: actions/checkout@v4
# 👇 Build steps
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 18.x
node-version: lts/*
cache: yarn
cache-dependency-path: documentation/yarn.lock
- name: Install dependencies

View File

@@ -2,6 +2,9 @@ name: pre-commit
on:
pull_request:
branches:
- master
- release/1.x
paths:
- src/**
- tests/**
@@ -12,6 +15,7 @@ on:
push:
branches:
- master
- release/1.x
paths:
- src/khoj/**
- tests/**
@@ -31,18 +35,24 @@ jobs:
with:
fetch-depth: 0
- name: Set up Python 3.11
uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
python-version: 3.11
version: "latest"
- name: Set up Python 3.11
run: uv python install 3.11
- name: ⏬️ Install Dependencies
run: |
sudo apt update && sudo apt install -y libegl1
python -m pip install --upgrade pip
- name: ⬇️ Install Application
run: pip install --no-cache-dir --upgrade .[dev]
env:
UV_INDEX: "https://download.pytorch.org/whl/cpu"
UV_INDEX_STRATEGY: "unsafe-best-match"
CUDA_VISIBLE_DEVICES: ""
run: uv sync --all-extras
- name: 🌡️ Validate Application
run: pre-commit run --hook-stage manual --all
run: uv run pre-commit run --hook-stage manual --all

View File

@@ -6,6 +6,7 @@ on:
- "*"
branches:
- 'master'
- 'release/1.x'
paths:
- src/khoj/**
- src/interface/web/**
@@ -14,6 +15,7 @@ on:
pull_request:
branches:
- 'master'
- 'release/1.x'
paths:
- src/khoj/**
- src/interface/web/**
@@ -32,25 +34,28 @@ jobs:
with:
fetch-depth: 0
- name: Set up Python 3.11
uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
python-version: '3.11.12'
version: "latest"
- name: Set up Python 3.11
run: uv python install 3.11.12
- name: ⬇️ Install Server
run: python -m pip install --upgrade pip && pip install --upgrade .
run: uv sync --all-extras
- name: Install bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: ⬇️ Install Web Client
run: |
yarn install
yarn pypiciexport
bun install
bun pypiciexport
working-directory: src/interface/web
- name: 📂 Copy Generated Files
run: |
mkdir -p src/khoj/interface/compiled
cp -r /opt/hostedtoolcache/Python/3.11.12/x64/lib/python3.11/site-packages/khoj/interface/compiled/* src/khoj/interface/compiled/
- name: ⚙️ Build Python Package
run: |
# Setup Environment for Reproducible Builds
@@ -59,13 +64,13 @@ jobs:
rm -rf dist
# Build PyPI Package
pipx run build
uv build
- name: 🌡️ Validate Python Package
run: |
# Validate PyPi Package
pipx run check-wheel-contents dist/*.whl --ignore W004
pipx run twine check dist/*
uv tool run check-wheel-contents dist/*.whl --ignore W002,W004
uv tool run twine check dist/*
- name: ⏫ Upload Python Package Artifacts
uses: actions/upload-artifact@v4
@@ -74,7 +79,7 @@ jobs:
path: dist/khoj-*.whl
- name: 📦 Publish Python Package to PyPI
if: startsWith(github.ref, 'refs/tags') || github.ref == 'refs/heads/master'
if: startsWith(github.ref, 'refs/tags') || github.ref == 'refs/heads/master' || github.ref == 'refs/heads/release/1.x'
uses: pypa/gh-action-pypi-publish@release/v1.12
with:
skip-existing: true

View File

@@ -43,7 +43,7 @@ on:
chat_model:
description: 'Chat model to use'
required: false
default: 'gemini-2.0-flash'
default: 'gemini-2.5-flash'
type: string
max_research_iterations:
description: 'Maximum number of iterations in research mode'
@@ -60,14 +60,6 @@ on:
required: false
default: 'https://api.openai.com/v1'
type: string
auto_read_webpage:
description: 'Auto read webpage on online search'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
randomize:
description: 'Randomize the sample of questions'
required: false
@@ -76,6 +68,11 @@ on:
options:
- 'false'
- 'true'
dataset_seed:
description: 'Seed to deterministically shuffle questions'
required: false
default: ''
type: string
jobs:
eval:
@@ -106,10 +103,13 @@ jobs:
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
python-version: '3.10'
version: "latest"
- name: Set up Python
run: uv python install 3.10
- name: Get App Version
id: hatch
@@ -127,16 +127,18 @@ jobs:
DEBIAN_FRONTEND: noninteractive
run: |
# install dependencies
sudo apt update && sudo apt install -y git python3-pip libegl1 sqlite3 libsqlite3-dev libsqlite3-0 ffmpeg libsm6 libxext6
# upgrade pip
python -m ensurepip --upgrade && python -m pip install --upgrade pip
sudo apt update && sudo apt install -y git libegl1 sqlite3 libsqlite3-dev libsqlite3-0 ffmpeg libsm6 libxext6
# install terrarium for code sandbox
git clone https://github.com/khoj-ai/terrarium.git && cd terrarium && npm install --legacy-peer-deps && mkdir pyodide_cache
- name: ⬇️ Install Application
env:
UV_INDEX: "https://download.pytorch.org/whl/cpu"
UV_INDEX_STRATEGY: "unsafe-best-match"
CUDA_VISIBLE_DEVICES: ""
run: |
sed -i 's/dynamic = \["version"\]/version = "${{ steps.hatch.outputs.version }}"/' pyproject.toml
pip install --upgrade .[dev]
uv sync --all-extras
- name: 📝 Run Eval
env:
@@ -144,9 +146,10 @@ jobs:
SAMPLE_SIZE: ${{ github.event_name == 'workflow_dispatch' && inputs.sample_size || 200 }}
BATCH_SIZE: "20"
RANDOMIZE: ${{ github.event_name == 'workflow_dispatch' && inputs.randomize || 'true' }}
KHOJ_URL: "http://localhost:42110"
DATASET_SEED: ${{ github.event_name == 'workflow_dispatch' && inputs.dataset_seed || github.run_id }}
KHOJ_LLM_SEED: "42"
KHOJ_DEFAULT_CHAT_MODEL: ${{ github.event_name == 'workflow_dispatch' && inputs.chat_model || 'gemini-2.0-flash' }}
KHOJ_URL: "http://localhost:42110"
KHOJ_DEFAULT_CHAT_MODEL: ${{ github.event_name == 'workflow_dispatch' && inputs.chat_model || 'gemini-2.5-flash' }}
KHOJ_RESEARCH_ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.max_research_iterations || 10 }}
KHOJ_AUTO_READ_WEBPAGE: ${{ github.event_name == 'workflow_dispatch' && inputs.auto_read_webpage || 'false' }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
@@ -167,8 +170,13 @@ jobs:
USE_EMBEDDED_DB: "true"
KHOJ_TELEMETRY_DISABLE: "True" # To disable telemetry for tests
run: |
set -euo pipefail
# Start Khoj server in background
khoj --anonymous-mode --non-interactive &
# Capture stdout/stderr to a log for debugging if startup fails
uv run khoj --anonymous-mode --non-interactive -vv > khoj_server.log 2>&1 &
KHOJ_PID=$!
echo "Started Khoj (PID=$KHOJ_PID)"
# Start code sandbox
npm install -g pm2
@@ -177,17 +185,25 @@ jobs:
# Wait for server to be ready
timeout=120
while ! curl -s http://localhost:42110/api/health > /dev/null; do
if [ $timeout -le 0 ]; then
echo "Timed out waiting for Khoj server"
# If process died, surface logs and fail fast
if ! kill -0 "$KHOJ_PID" 2>/dev/null; then
echo "Khoj process exited before becoming healthy. Logs:" >&2
sed -n '1,200p' khoj_server.log >&2 || true
exit 1
fi
echo "Waiting for Khoj server..."
if [ $timeout -le 0 ]; then
echo "Timed out waiting for Khoj server. Partial logs:" >&2
sed -n '1,200p' khoj_server.log >&2 || true
exit 1
fi
echo "Waiting for Khoj server... ($timeout s left)"
sleep 2
timeout=$((timeout-2))
done
echo "Khoj server is healthy"
# Run evals
python tests/evals/eval.py -d ${{ matrix.dataset }}
uv run python tests/evals/eval.py -d ${{ matrix.dataset }}
- name: Upload Results
if: always() # Upload results even if tests fail
@@ -197,6 +213,7 @@ jobs:
path: |
*_evaluation_results_*.csv
*_evaluation_summary_*.txt
khoj_server.log
- name: Display Results
if: always()
@@ -205,7 +222,7 @@ jobs:
echo "## Evaluation Summary of Khoj on ${{ matrix.dataset }} in ${{ matrix.khoj_mode }} mode" >> $GITHUB_STEP_SUMMARY
echo "**$(head -n 1 *_evaluation_summary_*.txt)**" >> $GITHUB_STEP_SUMMARY
echo "- Khoj Version: ${{ steps.hatch.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- Chat Model: ${{ inputs.chat_model || 'gemini-2.0-flash' }}" >> $GITHUB_STEP_SUMMARY
echo "- Chat Model: ${{ inputs.chat_model || 'gemini-2.5-flash' }}" >> $GITHUB_STEP_SUMMARY
echo "- Code Sandbox: ${{ inputs.sandbox || 'terrarium' }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
tail -n +2 *_evaluation_summary_*.txt >> $GITHUB_STEP_SUMMARY

View File

@@ -2,6 +2,9 @@ name: test
on:
pull_request:
branches:
- master
- release/1.x
paths:
- src/khoj/**
- tests/**
@@ -13,6 +16,7 @@ on:
push:
branches:
- master
- release/1.x
paths:
- src/khoj/**
- tests/**
@@ -50,18 +54,19 @@ jobs:
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
python-version: ${{ matrix.python_version }}
version: "latest"
- name: Set up Python ${{ matrix.python_version }}
run: uv python install ${{ matrix.python_version }}
- name: ⏬️ Install Dependencies
env:
DEBIAN_FRONTEND: noninteractive
run: |
apt update && apt install -y git libegl1 sqlite3 libsqlite3-dev libsqlite3-0 ffmpeg libsm6 libxext6
# required by llama-cpp-python prebuilt wheels
apt install -y musl-dev && ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1
- name: ⬇️ Install Postgres
env:
@@ -69,17 +74,12 @@ jobs:
run : |
apt install -y postgresql postgresql-client && apt install -y postgresql-server-dev-16
- name: ⬇️ Install pip
run: |
apt install -y python3-pip
python3 -m ensurepip --upgrade
python3 -m pip install --upgrade pip
- name: ⬇️ Install Application
env:
PIP_EXTRA_INDEX_URL: "https://download.pytorch.org/whl/cpu https://abetlen.github.io/llama-cpp-python/whl/cpu"
UV_INDEX: "https://download.pytorch.org/whl/cpu"
UV_INDEX_STRATEGY: "unsafe-best-match"
CUDA_VISIBLE_DEVICES: ""
run: sed -i 's/dynamic = \["version"\]/version = "0.0.0"/' pyproject.toml && pip install --break-system-packages --upgrade .[dev]
run: sed -i 's/dynamic = \["version"\]/version = "0.0.0"/' pyproject.toml && uv sync --all-extras
- name: 🧪 Test Application
env:
@@ -88,5 +88,6 @@ jobs:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
run: pytest
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: uv run pytest
timeout-minutes: 10

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- 'master'
- 'release/1.x'
paths:
- src/interface/emacs/*.el
- src/interface/emacs/tests/*.el
@@ -11,6 +12,7 @@ on:
pull_request:
branches:
- 'master'
- 'release/1.x'
paths:
- src/interface/emacs/*.el
- src/interface/emacs/tests/*.el
@@ -23,10 +25,9 @@ jobs:
fail-fast: false
matrix:
emacs_version:
- 27.1
- 27.2
- 28.1
- 28.2
- 29.4
- 30.2
- snapshot
steps:
- uses: purcell/setup-emacs@master

View File

@@ -1,8 +1,12 @@
repos:
- repo: https://github.com/psf/black
rev: 23.1.0
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.3
hooks:
- id: black
- id: ruff-check
args: [ --fix ]
files: \.py$
- id: ruff-format
files: \.py$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
@@ -12,17 +16,10 @@ repos:
# Exclude elisp files to not clear page breaks
exclude: \.el$
- id: check-json
exclude: (devcontainer\.json|launch\.json)$
exclude: (devcontainer\.json|launch\.json|settings\.json)$
- id: check-toml
- id: check-yaml
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black", "--filter-files"]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.0.0
hooks:

View File

@@ -3,5 +3,9 @@
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,
"python.analysis.include": [
"src/khoj/**/*"
],
"python.analysis.aiHoverSummaries": false,
}

View File

@@ -35,17 +35,17 @@ RUN sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.tom
pip install --no-cache-dir .
# Build Web App
FROM node:23-alpine AS web-app
FROM oven/bun:1-alpine AS web-app
# Set build optimization env vars
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/src/interface/web
# Install dependencies first (cache layer)
COPY src/interface/web/package.json src/interface/web/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY src/interface/web/package.json src/interface/web/bun.lock ./
RUN bun install --frozen-lockfile
# Copy source and build
COPY src/interface/web/. ./
RUN yarn build
RUN bun run build
# Merge the Server and Web App into a Single Image
FROM base

View File

@@ -34,8 +34,7 @@
***
### 🎁 New
* Start any message with `/research` to try out the experimental research mode with Khoj.
* Anyone can now [create custom agents](https://blog.khoj.dev/posts/create-agents-on-khoj/) with tunable personality, tools and knowledge bases.
* Meet 🌶️ **[Pipali](https://pipali.ai)** - our [open-source](https://github.com/khoj-ai/pipali) AI coworker that runs on your computer.
* [Read](https://blog.khoj.dev/posts/evaluate-khoj-quality/) about Khoj's excellent performance on modern retrieval and reasoning benchmarks.
***

View File

@@ -72,39 +72,31 @@ RUN apt update \
&& apt remove -y light-locker xfce4-screensaver xfce4-power-manager || true
# Create Computer User
ENV USERNAME=operator
ENV USERNAME=khoj
ENV HOME=/home/$USERNAME
RUN useradd -m -s /bin/bash -d $HOME -g $USERNAME $USERNAME && echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RUN groupadd $USERNAME && \
useradd -m -s /bin/bash -d $HOME -g $USERNAME $USERNAME && \
echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
USER $USERNAME
WORKDIR $HOME
# Setup Python
RUN git clone https://github.com/pyenv/pyenv.git ~/.pyenv && \
cd ~/.pyenv && src/configure && make -C src && cd .. && \
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc && \
echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc && \
echo 'eval "$(pyenv init -)"' >> ~/.bashrc
ENV PYENV_ROOT="$HOME/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
ENV PYENV_VERSION_MAJOR=3
ENV PYENV_VERSION_MINOR=11
ENV PYENV_VERSION_PATCH=6
ENV PYENV_VERSION=$PYENV_VERSION_MAJOR.$PYENV_VERSION_MINOR.$PYENV_VERSION_PATCH
RUN eval "$(pyenv init -)" && \
pyenv install $PYENV_VERSION && \
pyenv global $PYENV_VERSION && \
pyenv rehash
ENV PATH="$HOME/.pyenv/shims:$HOME/.pyenv/bin:$PATH"
# Install Python using uv and create a virtual environment
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHON_VERSION=3.11.6
RUN uv python pin $PYTHON_VERSION
RUN uv venv $HOME/.venv --python $PYTHON_VERSION --seed
RUN echo 'export PATH="$HOME/.venv/bin:$PATH"' >> "$HOME/.bashrc"
ENV PATH="$HOME/.venv/bin:$PATH"
# Install Python Packages
RUN python3 -m pip install --no-cache-dir \
RUN uv pip install --no-cache-dir \
pyautogui \
Pillow \
pyperclip \
pygetwindow
# Setup VNC
RUN x11vnc -storepasswd secret /home/operator/.vncpass
RUN x11vnc -storepasswd secret /home/khoj/.vncpass
ARG WIDTH=1024
ARG HEIGHT=768
@@ -115,13 +107,22 @@ ENV DISPLAY_NUM=$DISPLAY_NUM
ENV DISPLAY=":$DISPLAY_NUM"
# Expose VNC on port 5900
# run Xvfb, x11vnc, Xfce (no login manager)
EXPOSE 5900
CMD ["/bin/sh", "-c", " export XDG_RUNTIME_DIR=/run/user/$(id -u); \
mkdir -p $XDG_RUNTIME_DIR && chown $USERNAME:$USERNAME $XDG_RUNTIME_DIR && chmod 0700 $XDG_RUNTIME_DIR; \
# Start Virtual Display (Xvfb), Desktop Manager (XFCE) and Remote Viewer (X11 VNC)
CMD ["/bin/sh", "-c", " \
# Create and permission XDG_RUNTIME_DIR with sudo \n\
export XDG_RUNTIME_DIR=/run/user/$(id -u); \
sudo mkdir -p $XDG_RUNTIME_DIR && \
sudo chown $(id -u):$(id -g) $XDG_RUNTIME_DIR && \
sudo chmod 0700 $XDG_RUNTIME_DIR; \
\
# Start Virtual Display \n\
Xvfb $DISPLAY -screen 0 ${WIDTH}x${HEIGHT}x24 -dpi 96 -auth /home/$USERNAME/.Xauthority >/dev/null 2>&1 & \
sleep 1; \
xauth add $DISPLAY . $(mcookie); \
\
# Start VNC Server \n\
x11vnc -display $DISPLAY -forever -rfbauth /home/$USERNAME/.vncpass -listen 0.0.0.0 -rfbport 5900 >/dev/null 2>&1 & \
eval $(dbus-launch --sh-syntax) && \
startxfce4 & \

View File

@@ -95,14 +95,14 @@ services:
# Uncomment appropriate lines below to enable web results with Khoj
# Ensure you set your provider specific API keys.
# ---
# Free, Slower API. Does both web search and webpage read. Get API key from https://jina.ai/
# - JINA_API_KEY=your_jina_api_key
# Paid, Fast API. Only does web search. Get API key from https://serper.dev/
# - SERPER_DEV_API_KEY=your_serper_dev_api_key
# Paid, Fast, Open API. Only does webpage read. Get API key from https://firecrawl.dev/
# - FIRECRAWL_API_KEY=your_firecrawl_api_key
# Paid, Fast, Higher Read Success API. Only does webpage read. Get API key from https://olostep.com/
# Paid, Higher Read Success API. Only does webpage read. Get API key from https://olostep.com/
# - OLOSTEP_API_KEY=your_olostep_api_key
# Paid, Open API. Does both web search and webpage read. Get API key from https://firecrawl.dev/
# - FIRECRAWL_API_KEY=your_firecrawl_api_key
# Paid, Fast API. Does both web search and webpage read. Get API key from https://exa.ai/
# - EXA_API_KEY=your_exa_api_key
#
# Uncomment the necessary lines below to make your instance publicly accessible.
# Proceed with caution, especially if you are using anonymous mode.

View File

@@ -20,7 +20,7 @@ Add all the agents you want to use for your different use-cases like Writer, Res
### Chat Model Options
Add all the chat models you want to try, use and switch between for your different use-cases. For each chat model you add:
- `Chat model`: The name of an [OpenAI](https://platform.openai.com/docs/models), [Anthropic](https://docs.anthropic.com/en/docs/about-claude/models#model-names), [Gemini](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-models) or [Offline](https://huggingface.co/models?pipeline_tag=text-generation&library=gguf) chat model.
- `Model type`: The chat model provider like `OpenAI`, `Offline`.
- `Model type`: The chat model provider like `OpenAI`, `Google`.
- `Vision enabled`: Set to `true` if your model supports vision. This is currently only supported for vision capable OpenAI models like `gpt-4o`
- `Max prompt size`, `Subscribed max prompt size`: These are optional fields. They are used to truncate the context to the maximum context size that can be passed to the model. This can help with accuracy and cost-saving.<br />
- `Tokenizer`: This is an optional field. It is used to accurately count tokens and truncate context passed to the chat model to stay within the models max prompt size.
@@ -52,8 +52,8 @@ Search models are used to generate vector embeddings of your documents for natur
<img src="/img/example_search_model_admin_settings.png" alt="Example Search Model Settings" style={{width: 500}} />
### Text to Image Model Options
Add text to image generation models with these settings. Khoj currently supports text to image models available via OpenAI, Stability or Replicate API
- `api-key`: Set to your OpenAI, Stability or Replicate API key
Add text to image generation models with these settings. Khoj currently supports text to image models available via OpenAI, Google or Replicate API
- `api-key`: Set to your OpenAI, Google AI or Replicate API key
- `model`: Set the model name available over the selected model provider
- `model-type`: Set to the appropriate model provider
- `openai-config`: For image generation models available via OpenAI (compatible) API you can set the appropriate OpenAI Processor Conversation Settings instead of specifying the `api-key` field above

View File

@@ -24,7 +24,7 @@ It's still possible to use the magic links feature without Resend, but you'll ne
## Manually sending magic links
1. The user will have to enter their email address in the login page at http://localhost:42110/login.
1. The user will have to enter their email address in the login popup shown at http://localhost:42110/?v=app.
They'll click `Get Login Link`. Without the Resend API key, this will just create an unverified account for them in the backend
<img src="/img/magic_link.png" alt="Magic link login form" width="400"/>

View File

@@ -30,7 +30,7 @@ git clone https://github.com/khoj-ai/khoj && cd khoj
python3 -m venv .venv && source .venv/bin/activate
# For MacOS or zsh users run this
pip install -e '.[dev]'
uv sync --all-extras
```
</TabItem>
<TabItem value="windows" label="Windows">
@@ -42,7 +42,7 @@ git clone https://github.com/khoj-ai/khoj && cd khoj
python3 -m venv .venv && .venv\Scripts\activate
# Install Khoj for Development
pip install -e '.[dev]'
uv sync --all-extras
```
</TabItem>
<TabItem value="linux" label="Linux">
@@ -54,7 +54,7 @@ git clone https://github.com/khoj-ai/khoj && cd khoj
python3 -m venv .venv && source .venv/bin/activate
# Install Khoj for Development
pip install -e '.[dev]'
uv sync --all-extras
```
</TabItem>
</Tabs>
@@ -106,13 +106,13 @@ sudo -u postgres createdb khoj --password
```shell
cd src/interface/web/
yarn install
yarn export
bun install
bun export
```
You can optionally use `yarn dev` to start a development server for the front-end which will be available at http://localhost:3000. This is especially useful if you're making changes to the front-end code, but not necessary for running Khoj. Note that streaming does not work on the dev server due to how it is handled with SSR in Next.js.
You can optionally use `bun dev` to start a development server for the front-end which will be available at http://localhost:3000. This is especially useful if you're making changes to the front-end code, but not necessary for running Khoj. Note that streaming does not work on the dev server due to how it is handled with SSR in Next.js.
Always run `yarn export` to test your front-end changes on http://localhost:42110 before creating a PR.
Always run `bun export` to test your front-end changes on http://localhost:42110 before creating a PR.
#### 4. Run
1. Start Khoj
@@ -129,7 +129,7 @@ Always run `yarn export` to test your front-end changes on http://localhost:4211
- Try reactivating the virtual environment and rerunning the `khoj` command.
- If it still doesn't work repeat the installation process.
2. Python Package Missing
- Use `pip install xxx` and try running the `khoj` command.
- Use `uv add xxx` and try running the `khoj` command.
3. Command `createdb` Not Recognized
- make sure path to postgres binaries is included in environment variables. It usually looks something like
```

View File

@@ -17,7 +17,7 @@ Khoj supports a variety of features, including search and chat with a wide range
- **Works online or offline**: Chat using online or offline AI chat models
#### General
- **Cloud or Self-Host**: Use [cloud](https://app.khoj.dev/login) to use Khoj anytime from anywhere or [self-host](/get-started/setup) for privacy
- **Cloud or Self-Host**: Use [cloud](https://app.khoj.dev) to use Khoj anytime from anywhere or [self-host](/get-started/setup) for privacy
- **Natural**: Advanced natural language understanding using Transformer based ML Models
- **Pluggable**: Modular architecture makes it easy to plug in new data sources, frontends and ML models
- **Multiple Sources**: Index your Org-mode, Markdown, PDF, plaintext files, Github repos and Notion pages

View File

@@ -19,13 +19,14 @@ Try it out yourself! https://app.khoj.dev
Online search can work even with self-hosting! You have a few options:
- If you're using Docker, online search should work out of the box with [searxng](https://github.com/searxng/searxng) using our standard `docker-compose.yml`.
- For a non-local, free solution, you can use [JinaAI's reader API](https://jina.ai/reader/) to search online and read webpages. You can get a free API key via https://jina.ai/reader. Set the `JINA_API_KEY` environment variable to your Jina AI reader API key to enable online search.
- To get production-grade, fast online search, set the `SERPER_DEV_API_KEY` environment variable to your [Serper.dev](https://serper.dev/) API key. These search results include additional context like answer box, knowledge graph etc.
- To use open, self-hostable search provider, set the `FIRECRAWL_API_KEY` environment variable to your [Firecrawl](https://firecrawl.dev) API key. These search results do not scrape social media results.
- To use Exa search provider, set the `EXA_API_KEY` environment variable to your [Exa](https://exa.ai) API key.
### Webpage Reading
Out of the box, you **don't have to do anything to enable webpage reading**. Khoj will automatically read webpages by using the `requests` library. To get more distributed and scalable webpage reading, you can use the following options:
Out of the box, you **don't have to do anything to enable webpage reading**. Khoj will automatically read webpages by using the `requests` library. To get faster, more readable webpages for Khoj, you can use the following options:
- If you're using Jina AI's reader API for search, it should work automatically for webpage reading as well.
- For scalable webpage scraping, you can use [Firecrawl](https://www.firecrawl.dev/). Create a new [Webscraper](http://localhost:42110/server/admin/database/webscraper/add/). Set your Firecrawl API key to the Api Key field, and set the type to Firecrawl.
- For advanced webpage reading, you can use [Olostep](https://www.olostep.com/). This has a higher success rate at reading webpages than the default webpage readers. Create a new [Webscraper](http://localhost:42110/server/admin/database/webscraper/add/). Set your Olostep API key to the Api Key field, and set the type to Olostep.
- For open, self-hostable webpage reader, you can use [Firecrawl](https://www.firecrawl.dev/). Create a new [Webscraper](http://localhost:42110/server/admin/database/webscraper/add/). Set your Firecrawl API key to the Api Key field, and set the type to Firecrawl.
- For advanced webpage reading, you can use [Olostep](https://www.olostep.com/). This can read a wider variety of webpages. Create a new [Webscraper](http://localhost:42110/server/admin/database/webscraper/add/). Set your Olostep API key to the Api Key field, and set the type to Olostep.
- For fast webpage reading, you can use [Exa](https://exa.ai). Create a new [Webscraper](http://localhost:42110/server/admin/database/webscraper/add/). Set your Exa API key to the Api Key field, and set the type to Exa.

View File

@@ -28,7 +28,7 @@ Welcome to the Khoj Docs! This is the best place to get setup and explore Khoj's
- Quickly [find](/features/search) relevant notes and documents using natural language
- It understands pdf, plaintext, markdown, org-mode files, and [notion pages](/data-sources/notion_integration).
- Access it from your [Emacs](/clients/emacs), [Obsidian](/clients/obsidian), the [Khoj desktop app](/clients/desktop), or [any web browser](/clients/web)
- Use our [cloud](https://app.khoj.dev/login) instance to access your Khoj anytime from anywhere, [self-host](/get-started/setup) on consumer hardware for privacy
- Use our [cloud](https://app.khoj.dev) instance to access your Khoj anytime from anywhere, [self-host](/get-started/setup) on consumer hardware for privacy
![demo_chat](https://assets.khoj.dev/quadratic_equation_khoj_web.gif)

View File

@@ -17,7 +17,7 @@ Here's what to consider if you're using Khoj, whether self-hosted or on our clou
- If you're self-hosting, you can opt out of telemetry by following [these instructions](/miscellaneous/telemetry).
Self-hosting isn't for everyone, so we've still taken steps to make Khoj privacy-friendly, even if you choose to use our [cloud offering](https://app.khoj.dev/login). Here's what to consider when using Khoj Cloud:
Self-hosting isn't for everyone, so we've still taken steps to make Khoj privacy-friendly, even if you choose to use our [cloud offering](https://app.khoj.dev). Here's what to consider when using Khoj Cloud:
1. Your embeddings are generated by an open source model within our own dedicated endpoint [hosted on AWS with Huggingface](https://huggingface.co/inference-endpoints/dedicated). There's zero persistent memory to the Huggingface Inference endpoints (it's stateless).
1. Your embeddings and the associated raw text are stored in a secure Postgres DB in our private AWS cloud. Your data is sharded on a unique user ID. We store the raw text in your files to improve file syncing and provide context when you chat with Khoj.
1. When you use the single-sign-on option with Google, we only receive your name, a link to your profile photo, and your email address.

View File

@@ -18,10 +18,6 @@ import TabItem from '@theme/TabItem';
These are the general setup instructions for self-hosted Khoj.
You can install the Khoj server using either [Docker](?server=docker) or [Pip](?server=pip).
:::info[Offline Model + GPU]
To use the offline chat model with your GPU, we recommend using the Docker setup with Ollama . You can also use the local Khoj setup via the Python package directly.
:::
:::info[First Run]
Restart your Khoj server after the first run to ensure all settings are applied correctly.
:::
@@ -225,10 +221,6 @@ To start Khoj automatically in the background use [Task scheduler](https://www.w
You can now open the web app at http://localhost:42110 and start interacting!<br />
Nothing else is necessary, but you can customize your setup further by following the steps below.
:::info[First Message to Offline Chat Model]
The offline chat model gets downloaded when you first send a message to it. The download can take a few minutes! Subsequent messages should be faster.
:::
### Add Chat Models
<h4>Login to the Khoj Admin Panel</h4>
Go to http://localhost:42110/server/admin and login with the admin credentials you setup during installation.
@@ -287,7 +279,7 @@ Using Ollama? See the [Ollama Integration](/advanced/ollama) section for more cu
- Add your [Gemini API key](https://aistudio.google.com/app/apikey)
- Give the configuration a friendly name like `Gemini`. Do not configure the API base url.
2. Create a new [chat model](http://localhost:42110/server/admin/database/chatmodel/add)
- Set the `chat-model` field to a [Google Gemini chat model](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-models). Example: `gemini-2.0-flash`.
- Set the `chat-model` field to a [Google Gemini chat model](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-models). Example: `gemini-2.5-flash`.
- Set the `model-type` field to `Google`.
- Set the `ai model api` field to the Gemini AI Model API you created in step 1.
@@ -301,13 +293,14 @@ Offline chat stays completely private and can work without internet using any op
- A Nvidia, AMD GPU or a Mac M1+ machine would significantly speed up chat responses
:::
1. Get the name of your preferred chat model from [HuggingFace](https://huggingface.co/models?pipeline_tag=text-generation&library=gguf). *Most GGUF format chat models are supported*.
2. Open the [create chat model page](http://localhost:42110/server/admin/database/chatmodel/add/) on the admin panel
3. Set the `chat-model` field to the name of your preferred chat model
- Make sure the `model-type` is set to `Offline`
4. Set the newly added chat model as your preferred model in your [User chat settings](http://localhost:42110/settings) and [Server chat settings](http://localhost:42110/server/admin/database/serverchatsettings/).
5. Restart the Khoj server and [start chatting](http://localhost:42110) with your new offline model!
</TabItem>
1. Install any Openai API compatible local ai model server like [llama-cpp-server](https://github.com/ggml-org/llama.cpp/tree/master/tools/server), Ollama, vLLM etc.
2. Add an [ai model api](http://localhost:42110/server/admin/database/aimodelapi/add/) on the admin panel
- Set the `api url` field to the url of your local ai model provider like `http://localhost:11434/v1/` for Ollama
3. Restart the Khoj server to load models available on your local ai model provider
- If that doesn't work, you'll need to manually add available [chat model](http://localhost:42110/server/admin/database/chatmodel/add) in the admin panel.
4. Set the newly added chat model as your preferred model in your [User chat settings](http://localhost:42110/settings)
5. [Start chatting](http://localhost:42110) with your local AI!
</TabItem>
</Tabs>
:::tip[Multiple Chat Models]

View File

@@ -27,7 +27,11 @@ const config = {
projectName: 'khoj', // Usually your repo name.
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'warn',
markdown: {
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
@@ -99,7 +103,7 @@ const config = {
'aria-label': 'GitHub repository',
},
{
href: 'https://app.khoj.dev/login',
href: 'https://app.khoj.dev',
position: 'right',
className: 'header-cloud-link',
title: 'Khoj Cloud',
@@ -187,14 +191,14 @@ const config = {
},
{
label: 'Khoj Cloud',
href: 'https://app.khoj.dev/login',
href: 'https://app.khoj.dev',
},
{
label: 'GitHub',
href: 'https://github.com/khoj-ai/khoj',
},
{
label: 'Website',
label: 'Khoj Inc.',
href: 'https://khoj.dev',
},
],

View File

@@ -14,18 +14,19 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "^3.2.1",
"@docusaurus/plugin-sitemap": "^3.2.1",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/core": "^3.9.2",
"@docusaurus/plugin-sitemap": "^3.9.2",
"@docusaurus/preset-classic": "^3.9.2",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react-dom": "^19.1.0",
"webpack-dev-server": "^5.2.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.2.1",
"@docusaurus/types": "^3.2.1"
"@docusaurus/module-type-aliases": "^3.9.2",
"@docusaurus/types": "^3.9.2"
},
"browserslist": {
"production": [
@@ -43,6 +44,8 @@
"node": ">=18.0"
},
"resolutions": {
"webpack-dev-server": "^5.2.1"
"webpack-dev-server": "^5.2.1",
"serialize-javascript": "^7.0.3",
"picomatch": ">=2.3.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"id": "khoj",
"name": "Khoj",
"version": "1.42.8",
"version": "2.0.0-beta.28",
"minAppVersion": "0.15.0",
"description": "Your Second Brain",
"author": "Khoj Inc.",

View File

@@ -34,17 +34,17 @@ RUN sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.tom
pip install --no-cache-dir -e .[prod]
# Build Web App
FROM node:20-alpine AS web-app
FROM oven/bun:1-alpine AS web-app
# Set build optimization env vars
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app/src/interface/web
# Install dependencies first (cache layer)
COPY src/interface/web/package.json src/interface/web/yarn.lock ./
RUN yarn install --frozen-lockfile
COPY src/interface/web/package.json src/interface/web/bun.lock ./
RUN bun install --frozen-lockfile
# Copy source and build
COPY src/interface/web/. ./
RUN yarn build
RUN bun run build
# Merge the Server and Web App into a Single Image
FROM base

View File

@@ -40,43 +40,43 @@ dependencies = [
"dateparser >= 1.1.1",
"defusedxml == 0.7.1",
"fastapi >= 0.110.0",
"python-multipart >= 0.0.7",
"python-multipart >= 0.0.22",
"jinja2 == 3.1.6",
"openai >= 1.86.0",
"openai >= 2.0.0, < 3.0.0",
"tiktoken >= 0.3.2",
"tenacity >= 9.0.0",
"magika ~= 0.5.1",
"pillow ~= 10.0.0",
"pillow >= 12.1.1",
"pydantic[email] >= 2.0.0",
"pyyaml ~= 6.0",
"rich >= 13.3.1",
"click < 8.2.2",
"schedule == 1.1.0",
"sentence-transformers == 3.4.1",
"einops == 0.8.0",
"transformers >= 4.51.0",
"transformers >= 4.53.0",
"torch == 2.6.0",
"uvicorn == 0.30.6",
"aiohttp ~= 3.9.0",
"langchain-text-splitters == 0.3.1",
"langchain-community == 0.3.3",
"requests >= 2.26.0",
"uvicorn >= 0.31.1",
"aiohttp ~= 3.13.0",
"langchain-text-splitters == 0.3.11",
"langchain-community == 0.3.31",
"requests >= 2.33.0",
"anyio ~= 4.8.0",
"pymupdf == 1.24.11",
"django == 5.1.10",
"django == 5.1.15",
"django-unfold == 0.42.0",
"authlib == 1.2.1",
"llama-cpp-python == 0.2.88",
"authlib == 1.6.9",
"itsdangerous == 2.1.2",
"httpx == 0.28.1",
"pgvector == 0.2.4",
"psycopg2-binary == 2.9.9",
"lxml == 4.9.3",
"tzdata == 2023.3",
"rapidocr-onnxruntime == 1.3.24",
"rapidocr-onnxruntime == 1.4.4",
"openai-whisper >= 20231117",
"django-phonenumber-field == 7.3.0",
"phonenumbers == 8.13.27",
"markdownify ~= 0.11.6",
"markdownify ~= 0.14.1",
"markdown-it-py ~= 3.0.0",
"websockets == 13.0",
"psutil >= 5.8.0",
@@ -85,14 +85,16 @@ dependencies = [
"pytz ~= 2024.1",
"cron-descriptor == 1.4.3",
"django_apscheduler == 0.7.0",
"anthropic == 0.52.0",
"anthropic == 0.75.0",
"docx2txt == 0.8",
"google-genai == 1.11.0",
"google-genai == 1.52.0",
"google-auth ~= 2.23.3",
"pyjson5 == 1.6.7",
"resend == 1.0.1",
"resend == 1.2.0",
"email-validator == 2.2.0",
"e2b-code-interpreter ~= 1.0.0",
"mcp >= 1.23.0",
"pyasn1>=0.6.3",
]
dynamic = ["version"]
@@ -123,7 +125,7 @@ dev = [
"freezegun >= 1.2.0",
"factory-boy >= 3.2.1",
"mypy >= 1.0.1",
"black >= 23.1.0",
"ruff >= 0.12.0",
"pre-commit >= 3.0.4",
"gitpython ~= 3.1.43",
"datasets",
@@ -150,11 +152,34 @@ non_interactive = true
show_error_codes = true
warn_unused_ignores = false
[tool.black]
[tool.ruff]
line-length = 120
[tool.isort]
profile = "black"
[tool.ruff.lint]
select = ["E", "F", "I"] # Enable error, warning, and import checks
ignore = [
"E501", # Ignore line length
"F405", # Ignore name not defined (e.g., from imports)
"E402", # Ignore module level import not at top of file
]
unfixable = ["F841"] # Don't auto-remove unused variables
exclude = [ "tests/*.py" ]
[tool.ruff.lint.per-file-ignores]
"src/khoj/main.py" = [
"I001", # Ignore Import order
"I002", # Ignore Import not at top of file
"E402", # Ignore module level import not at top of file
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.ruff.lint.isort]
known-first-party = ["khoj"]
[tool.uv]
build-constraint-dependencies = ["setuptools>=61.2,<82"]
[tool.pytest.ini_options]
addopts = "--strict-markers"

View File

@@ -2,9 +2,80 @@
project_root=$PWD
while getopts 'nc:t:' opt;
while getopts 'nc:t:p:' opt;
do
case "${opt}" in
p)
# Create pre-release SemVer version. Options: alpha, beta, rc
prerelease_type=$OPTARG
# Get the current version from web package.json to determine base version
cd $project_root/src/interface/web
current_base_version=$(grep '"version":' package.json | awk -F '"' '{print $4}')
# Extract base version (remove any existing pre-release suffix)
base_version=$(echo $current_base_version | sed 's/-.*$//')
# If current version is already 2.x.x, increment the pre-release number
if [[ $current_base_version == *"-$prerelease_type"* ]]; then
# Extract current pre-release number and increment
current_num=$(echo $current_base_version | sed "s/.*-$prerelease_type\.//" | sed 's/[^0-9]*$//')
next_num=$((current_num + 1))
current_version="$base_version-$prerelease_type.$next_num"
else
# If base version is 1.x.x, bump to 2.0.0-prerelease.1
if [[ $base_version == 1.* ]]; then
current_version="2.0.0-$prerelease_type.1"
else
# Otherwise add pre-release to current base version
current_version="$base_version-$prerelease_type.1"
fi
fi
# Bump Web app to pre-release version
cd $project_root/src/interface/web
yarn version --new-version $current_version --no-git-tag-version
# Bump Desktop app to pre-release version
cd $project_root/src/interface/desktop
yarn version --new-version $current_version --no-git-tag-version
# Bump Obsidian plugin to pre-release version
cd $project_root/src/interface/obsidian
yarn build # verify build before bumping version
yarn version --new-version $current_version --no-git-tag-version
# append current version, min Obsidian app version from manifest to versions json
cp $project_root/versions.json .
yarn run version # run Obsidian version script
# Bump Emacs package to pre-release version
cd ../emacs
sed -E -i.bak "s/^;; Version: (.*)/;; Version: $current_version/" khoj.el
git add khoj.el
rm *.bak
# Copy current obsidian versioned files to project root
cd $project_root
cp src/interface/obsidian/versions.json .
cp src/interface/obsidian/manifest.json .
# Run pre-commit validation to fix jsons
pre-commit run --hook-stage manual --all
# Commit changes and tag commit for pre-release
git add \
$project_root/src/interface/web/package.json \
$project_root/src/interface/desktop/package.json \
$project_root/src/interface/obsidian/package.json \
$project_root/src/interface/obsidian/yarn.lock \
$project_root/src/interface/obsidian/manifest.json \
$project_root/src/interface/obsidian/versions.json \
$project_root/src/interface/emacs/khoj.el \
$project_root/manifest.json \
$project_root/versions.json
git commit -m "Release Khoj version $current_version"
git tag $current_version
;;
t)
# Get version type to bump. Options: major, minor, patch
version_type=$OPTARG
@@ -54,7 +125,7 @@ do
$project_root/manifest.json \
$project_root/versions.json
git commit -m "Release Khoj version $current_version"
git tag $current_version master
git tag $current_version
;;
c)
# Get current project version
@@ -101,7 +172,7 @@ do
$project_root/manifest.json \
$project_root/versions.json
git commit -m "Release Khoj version $current_version"
git tag $current_version master
git tag $current_version
;;
n)
# Induce hatch to compute next version number
@@ -144,7 +215,11 @@ do
git commit -m "Bump Khoj to pre-release version $next_version"
;;
?)
echo -e "Invalid command option.\nUsage: $(basename $0) [-t] [-c] [-n]"
echo -e "Invalid command option.\nUsage: $(basename $0) [-t type] [-c version] [-p prerelease] [-n]"
echo -e " -t: Bump version by type (major, minor, patch)"
echo -e " -c: Set specific version"
echo -e " -p: Create pre-release version (alpha, beta, rc)"
echo -e " -n: Compute and set next version using hatch"
exit 1
;;
esac

View File

@@ -7,11 +7,11 @@ INSTALL_FULL=false
DEVCONTAINER=false
for arg in "$@"
do
if [ "$arg" == "--full" ]
if [ "$arg" = "--full" ]
then
INSTALL_FULL=true
fi
if [ "$arg" == "--devcontainer" ]
if [ "$arg" = "--devcontainer" ]
then
DEVCONTAINER=true
fi
@@ -24,26 +24,40 @@ if [ "$DEVCONTAINER" = true ]; then
# Use devcontainer launch.json
mkdir -p .vscode && cp .devcontainer/launch.json .vscode/launch.json
# Activate the pre-installed venv (no need to create new one)
echo "Using Python environment at /opt/venv"
# PATH should already include /opt/venv/bin from Dockerfile
# Install khoj in editable mode (dependencies already installed)
python3 -m pip install -e '.[dev]'
# Install Server App using pre-installed dependencies
echo "Setup Server App with UV. Use pre-installed dependencies in $UV_PROJECT_ENVIRONMENT."
sed -i "s/dynamic = \\[\"version\"\\]/version = \"$VERSION\"/" pyproject.toml
cp /opt/uv.lock.linux uv.lock
uv sync --all-extras
# Install Web App using cached dependencies
echo "Installing Web App using cached dependencies..."
echo "Setup Web App with Bun. Use pre-installed dependencies in /opt/khoj_web."
cd "$PROJECT_ROOT/src/interface/web"
yarn install --cache-folder /opt/yarn-cache && yarn export
ln -sf /opt/khoj_web/node_modules node_modules
bun install && bun run ciexport
else
# Standard setup
echo "Installing Server App..."
cd "$PROJECT_ROOT"
python3 -m venv .venv && . .venv/bin/activate && python3 -m pip install -e '.[dev]'
if command -v uv &> /dev/null
then
uv venv
uv sync --all-extras
else
python3 -m venv .venv && . .venv/bin/activate
python3 -m pip install -e '.[dev]'
fi
echo "Installing Web App..."
cd "$PROJECT_ROOT/src/interface/web"
yarn install && yarn export
if command -v bun &> /dev/null
then
echo "using Bun."
bun install && bun run export
else
echo "using Yarn."
yarn install && yarn export
fi
fi
# Install Obsidian App

View File

@@ -17,7 +17,6 @@ package dev.khoj.app;
import android.content.pm.ActivityInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -35,11 +34,7 @@ public class LauncherActivity
// Oreo and below. We only set the orientation on Oreo and above. This only affects the
// splash screen and Chrome will still respect the orientation.
// See https://github.com/GoogleChromeLabs/bubblewrap/issues/496 for details.
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
} else {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
}
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
}
@Override

View File

@@ -1,4 +1,4 @@
function copyParentText(event, message=null) { //same
function copyParentText(event, message=null) {
const button = event.currentTarget;
const textContent = message ?? button.parentNode.textContent.trim();
navigator.clipboard.writeText(textContent).then(() => {
@@ -17,19 +17,19 @@ function copyParentText(event, message=null) { //same
});
}
function createCopyParentText(message) { //same
function createCopyParentText(message) {
return function(event) {
copyParentText(event, message);
}
}
function formatDate(date) { //same
function formatDate(date) {
// Format date in HH:MM, DD MMM YYYY format
let time_string = date.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: false });
let date_string = date.toLocaleString('en-IN', { year: 'numeric', month: 'short', day: '2-digit'}).replaceAll('-', ' ');
return `${time_string}, ${date_string}`;
}
function generateReference(referenceJson, index) { //same
function generateReference(referenceJson, index) {
let reference = referenceJson.hasOwnProperty("compiled") ? referenceJson.compiled : referenceJson;
let referenceFile = referenceJson.hasOwnProperty("file") ? referenceJson.file : null;
@@ -62,7 +62,7 @@ function generateReference(referenceJson, index) { //same
return referenceButton;
}
function generateOnlineReference(reference, index) { //same
function generateOnlineReference(reference, index) {
// Generate HTML for Chat Reference
let title = reference.title || reference.link;
@@ -107,7 +107,7 @@ function generateOnlineReference(reference, index) { //same
return referenceButton;
}
function renderMessage(message, by, dt=null, annotations=null, raw=false, renderType="append") { //same
function renderMessage(message, by, dt=null, annotations=null, raw=false, renderType="append") {
let message_time = formatDate(dt ?? new Date());
let by_name = by == "khoj" ? "🏮 Khoj" : "🤔 You";
let formattedMessage = formatHTMLMessage(message, raw);
@@ -145,7 +145,7 @@ function renderMessage(message, by, dt=null, annotations=null, raw=false, render
chatBodyWrapperHeight = chatBodyWrapper.clientHeight;
}
function processOnlineReferences(referenceSection, onlineContext) { //same
function processOnlineReferences(referenceSection, onlineContext) {
let numOnlineReferences = 0;
for (let subquery in onlineContext) {
let onlineReference = onlineContext[subquery];

View File

@@ -1,6 +1,6 @@
{
"name": "Khoj",
"version": "1.42.8",
"version": "2.0.0-beta.28",
"description": "Your Second Brain",
"author": "Khoj Inc. <team@khoj.dev>",
"license": "GPL-3.0-or-later",
@@ -10,15 +10,19 @@
"main": "main.js",
"private": false,
"devDependencies": {
"electron": "28.3.2"
"electron": "35.7.5"
},
"scripts": {
"start": "yarn electron ."
},
"dependencies": {
"@todesktop/runtime": "^2.0.0",
"axios": "^1.8.2",
"axios": "^1.13.5",
"cron": "^2.4.3",
"electron-store": "^8.1.0"
},
"resolutions": {
"ajv": "^8.18.0",
"picomatch": ">=2.3.2"
}
}

View File

@@ -73,9 +73,9 @@
"@types/responselike" "^1.0.0"
"@types/http-cache-semantics@*":
version "4.0.4"
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4"
integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==
version "4.2.0"
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#f6a7788f438cbfde15f29acad46512b4c01913b3"
integrity sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==
"@types/keyv@^3.1.4":
version "3.1.4"
@@ -90,18 +90,18 @@
integrity sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==
"@types/node@*":
version "24.0.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.10.tgz#f65a169779bf0d70203183a1890be7bee8ca2ddb"
integrity sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==
version "25.5.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31"
integrity sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==
dependencies:
undici-types "~7.8.0"
undici-types "~7.18.0"
"@types/node@^18.11.18":
version "18.19.115"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.115.tgz#cd94caf14472021b4443c99bcd7aac6bb5c4f672"
integrity sha512-kNrFiTgG4a9JAn1LMQeLOv3MvXIPokzXziohMrMsvpYgLpdEt/mMiVYc4sGKtDfyxM5gIDF4VgrPRyCw4fHOYg==
"@types/node@^22.7.7":
version "22.19.15"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.15.tgz#6091d99fdf7c08cb57dc8b1345d407ba9a1df576"
integrity sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==
dependencies:
undici-types "~5.26.4"
undici-types "~6.21.0"
"@types/responselike@^1.0.0":
version "1.0.3"
@@ -132,10 +132,10 @@ ajv-formats@^2.1.1:
dependencies:
ajv "^8.0.0"
ajv@^8.0.0, ajv@^8.6.3:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6"
integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==
ajv@^8.0.0, ajv@^8.18.0, ajv@^8.6.3:
version "8.18.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc"
integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==
dependencies:
fast-deep-equal "^3.1.3"
fast-uri "^3.0.1"
@@ -162,13 +162,13 @@ atomically@^1.7.0:
resolved "https://registry.yarnpkg.com/atomically/-/atomically-1.7.0.tgz#c07a0458432ea6dbc9a3506fffa424b48bccaafe"
integrity sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==
axios@^1.8.2:
version "1.10.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.10.0.tgz#af320aee8632eaf2a400b6a1979fa75856f38d54"
integrity sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==
axios@^1.13.5:
version "1.13.6"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.6.tgz#c3f92da917dc209a15dd29936d20d5089b6b6c98"
integrity sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
follow-redirects "^1.15.11"
form-data "^4.0.5"
proxy-from-env "^1.1.0"
balanced-match@^1.0.0:
@@ -201,10 +201,10 @@ buffer-crc32@~0.2.3:
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
builder-util-runtime@9.3.1:
version "9.3.1"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz#0daedde0f6d381f2a00a50a407b166fe7dca1a67"
integrity sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==
builder-util-runtime@9.5.1:
version "9.5.1"
resolved "https://registry.yarnpkg.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz#74125fb374d1ecbf472ae1787485485ff7619702"
integrity sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==
dependencies:
debug "^4.3.4"
sax "^1.2.4"
@@ -300,9 +300,9 @@ debounce-fn@^4.0.0:
mimic-fn "^3.0.0"
debug@^4.1.0, debug@^4.1.1, debug@^4.3.4:
version "4.4.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies:
ms "^2.1.3"
@@ -392,26 +392,26 @@ electron-store@^8.1.0:
type-fest "^2.17.0"
electron-updater@^6.3.9:
version "6.6.2"
resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-6.6.2.tgz#3e65e044f1a99b00d61e200e24de8e709c69ce99"
integrity sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==
version "6.8.3"
resolved "https://registry.yarnpkg.com/electron-updater/-/electron-updater-6.8.3.tgz#bb0c8ef6509e5c67663f6481a729244d1bce21fb"
integrity sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==
dependencies:
builder-util-runtime "9.3.1"
builder-util-runtime "9.5.1"
fs-extra "^10.1.0"
js-yaml "^4.1.0"
lazy-val "^1.0.5"
lodash.escaperegexp "^4.1.2"
lodash.isequal "^4.5.0"
semver "^7.6.3"
semver "~7.7.3"
tiny-typed-emitter "^2.1.0"
electron@28.3.2:
version "28.3.2"
resolved "https://registry.yarnpkg.com/electron/-/electron-28.3.2.tgz#5bf674fe9a440e5d8e51627c66fc8d4bce4c409f"
integrity sha512-bmrQpdncbYNTArlg4n+qsASoXy3eeCELxeRmwUS52RNgvio1gGx5FLCwf8d4R+TsxwfkDWOaWbW0taIKheivKA==
electron@35.7.5:
version "35.7.5"
resolved "https://registry.yarnpkg.com/electron/-/electron-35.7.5.tgz#294a4aebb2ad2a884de730c410f2358d061e8d53"
integrity sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==
dependencies:
"@electron/get" "^2.0.0"
"@types/node" "^18.11.18"
"@types/node" "^22.7.7"
extract-zip "^2.0.1"
end-of-stream@^1.1.0:
@@ -511,14 +511,14 @@ fast-glob@^3.2.9:
micromatch "^4.0.8"
fast-uri@^3.0.1:
version "3.0.6"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.6.tgz#88f130b77cfaea2378d56bf970dea21257a68748"
integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==
version "3.1.0"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa"
integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==
fastq@^1.6.0:
version "1.19.1"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
version "1.20.1"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"
integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==
dependencies:
reusify "^1.0.4"
@@ -543,15 +543,15 @@ find-up@^3.0.0:
dependencies:
locate-path "^3.0.0"
follow-redirects@^1.15.6:
version "1.15.9"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
follow-redirects@^1.15.11:
version "1.15.11"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==
form-data@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.3.tgz#608b1b3f3e28be0fccf5901fc85fb3641e5cf0ae"
integrity sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==
form-data@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053"
integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
@@ -811,9 +811,9 @@ isexe@^2.0.0:
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
version "4.1.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b"
integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==
dependencies:
argparse "^2.0.1"
@@ -845,9 +845,9 @@ jsonfile@^4.0.0:
graceful-fs "^4.1.6"
jsonfile@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
version "6.2.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.0.tgz#7c265bd1b65de6977478300087c99f1c84383f62"
integrity sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==
dependencies:
universalify "^2.0.0"
optionalDependencies:
@@ -961,9 +961,9 @@ mimic-response@^3.1.0:
integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
minimatch@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
version "3.1.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
dependencies:
brace-expansion "^1.1.7"
@@ -1059,10 +1059,10 @@ pend@~1.2.0:
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
picomatch@>=2.3.2, picomatch@^2.3.1:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
pkg-up@^3.1.0:
version "3.1.0"
@@ -1082,9 +1082,9 @@ proxy-from-env@^1.1.0:
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
pump@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d"
integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==
version "3.0.4"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.4.tgz#1f313430527fa8b905622ebd22fe1444e757ab3c"
integrity sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
@@ -1148,9 +1148,9 @@ run-parallel@^1.1.9:
queue-microtask "^1.2.2"
sax@^1.2.4:
version "1.4.1"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f"
integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==
version "1.6.0"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.6.0.tgz#da59637629307b97e7c4cb28e080a7bc38560d5b"
integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==
semver-compare@^1.0.0:
version "1.0.0"
@@ -1162,10 +1162,10 @@ semver@^6.2.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.2, semver@^7.3.5, semver@^7.6.3:
version "7.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
semver@^7.3.2, semver@^7.3.5, semver@^7.6.3, semver@~7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
serialize-error@^7.0.1:
version "7.0.1"
@@ -1235,15 +1235,15 @@ type-fest@^2.17.0:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
undici-types@~7.8.0:
version "7.8.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
undici-types@~7.18.0:
version "7.18.2"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9"
integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==
universalify@^0.1.0:
version "0.1.2"

View File

@@ -6,7 +6,7 @@
;; Saba Imran <saba@khoj.dev>
;; Description: Your Second Brain
;; Keywords: search, chat, ai, org-mode, outlines, markdown, pdf, image
;; Version: 1.42.8
;; Version: 2.0.0-beta.28
;; Package-Requires: ((emacs "27.1") (transient "0.3.0") (dash "2.19.1"))
;; URL: https://github.com/khoj-ai/khoj/tree/master/src/interface/emacs

View File

@@ -1,7 +1,7 @@
{
"id": "khoj",
"name": "Khoj",
"version": "1.42.8",
"version": "2.0.0-beta.28",
"minAppVersion": "0.15.0",
"description": "Your Second Brain",
"author": "Khoj Inc.",

View File

@@ -1,6 +1,6 @@
{
"name": "Khoj",
"version": "1.42.8",
"version": "2.0.0-beta.28",
"description": "Your Second Brain",
"author": "Debanjum Singh Solanky, Saba Imran <team@khoj.dev>",
"license": "GPL-3.0-or-later",
@@ -29,7 +29,10 @@
"typescript": "4.7.4"
},
"dependencies": {
"diff": "^8.0.2",
"diff": "^8.0.3",
"isomorphic-dompurify": "^2.25.0"
},
"resolutions": {
"picomatch": ">=2.3.2"
}
}

View File

@@ -0,0 +1,30 @@
export async function deleteContentByType(khojUrl: string, khojApiKey: string, contentType: string): Promise<void> {
// Deletes all content of a given type on Khoj server for Obsidian client
const response = await fetch(`${khojUrl}/api/content/type/${contentType}?client=obsidian`, {
method: 'DELETE',
headers: khojApiKey ? { 'Authorization': `Bearer ${khojApiKey}` } : {},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Failed to delete content type ${contentType}: ${response.status} ${text}`);
}
}
export async function uploadContentBatch(khojUrl: string, khojApiKey: string, files: { blob: Blob, path: string }[]): Promise<string> {
// Uploads a batch of files to Khoj content endpoint
const formData = new FormData();
files.forEach(fileItem => { formData.append('files', fileItem.blob, fileItem.path); });
const response = await fetch(`${khojUrl}/api/content?client=obsidian`, {
method: 'PATCH',
headers: khojApiKey ? { 'Authorization': `Bearer ${khojApiKey}` } : {},
body: formData,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Failed to upload batch: ${response.status} ${text}`);
}
return await response.text();
}

View File

@@ -1,9 +1,9 @@
import { ItemView, MarkdownRenderer, Scope, WorkspaceLeaf, request, requestUrl, setIcon, Platform, TFile } from 'obsidian';
import * as DOMPurify from 'isomorphic-dompurify';
import { KhojSetting } from 'src/settings';
import { KhojPaneView } from 'src/pane_view';
import { KhojView, createCopyParentText, getLinkToEntry, pasteTextAtCursor } from 'src/utils';
import { KhojSearchModal } from 'src/search_modal';
import Khoj from 'src/main';
import { FileInteractions, EditBlock } from 'src/interact_with_files';
export interface ChatJsonResult {
@@ -67,12 +67,12 @@ interface Agent {
export class KhojChatView extends KhojPaneView {
result: string;
setting: KhojSetting;
waitingForLocation: boolean;
location: Location = { timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
keyPressTimeout: NodeJS.Timeout | null = null;
userMessages: string[] = []; // Store user sent messages for input history cycling
currentMessageIndex: number = -1; // Track current message index in userMessages array
voiceChatActive: boolean = false; // Flag to track if voice chat is active
private currentUserInput: string = ""; // Stores the current user input that is being typed in chat
private startingMessage: string = this.getLearningMoment();
chatMessageState: ChatMessageState;
@@ -101,10 +101,13 @@ export class KhojChatView extends KhojPaneView {
// 2. Higher invalid edit blocks than tolerable
private maxEditRetries: number = 1; // Maximum retries for edit blocks
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf, setting);
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
super(leaf, plugin);
this.fileInteractions = new FileInteractions(this.app);
// Initialize file access mode from persisted settings
this.fileAccessMode = this.setting.fileAccessMode ?? 'read';
this.waitingForLocation = true;
fetch("https://ipapi.co/json")
@@ -127,9 +130,9 @@ export class KhojChatView extends KhojPaneView {
// Register chat view keybindings
this.scope = new Scope(this.app.scope);
this.scope.register(["Ctrl", "Alt"], 'n', (_) => this.createNewConversation());
this.scope.register(["Ctrl", "Alt"], 'n', (_) => this.createNewConversation(this.currentAgent));
this.scope.register(["Ctrl", "Alt"], 'o', async (_) => await this.toggleChatSessions());
this.scope.register(["Ctrl", "Alt"], 'v', (_) => this.speechToText(new KeyboardEvent('keydown')));
this.scope.register(["Ctrl", "Alt"], 'v', (_) => this.speechToText(this.voiceChatActive ? new KeyboardEvent('keyup') : new KeyboardEvent('keydown')));
this.scope.register(["Ctrl"], 'f', (_) => new KhojSearchModal(this.app, this.setting).open());
this.scope.register(["Ctrl"], 'r', (_) => { this.activateView(KhojView.SIMILAR); });
}
@@ -220,7 +223,7 @@ export class KhojChatView extends KhojPaneView {
await this.fetchAgents();
// Populate the agent selector in the header
const headerAgentSelect = this.contentEl.querySelector('#khoj-header-agent-select') as HTMLSelectElement;
const headerAgentSelect = this.contentEl.querySelector('.khoj-header-agent-select') as HTMLSelectElement;
if (headerAgentSelect && this.agents.length > 0) {
// Clear existing options
headerAgentSelect.innerHTML = '';
@@ -272,29 +275,48 @@ export class KhojChatView extends KhojPaneView {
text: "File Access",
attr: {
class: "khoj-input-row-button clickable-icon",
title: "Toggle file access mode (Read Only)",
title: "Toggle open file access",
},
});
setIcon(fileAccessButton, "file-search");
fileAccessButton.addEventListener('click', () => {
// Set initial icon based on persisted setting
switch (this.fileAccessMode) {
case 'none':
setIcon(fileAccessButton, "file-x");
fileAccessButton.title = "Toggle open file access (No Access)";
break;
case 'write':
setIcon(fileAccessButton, "file-edit");
fileAccessButton.title = "Toggle open file access (Read & Write)";
break;
case 'read':
default:
setIcon(fileAccessButton, "file-search");
fileAccessButton.title = "Toggle open file access (Read Only)";
break;
}
fileAccessButton.addEventListener('click', async () => {
// Cycle through modes: none -> read -> write -> none
switch (this.fileAccessMode) {
case 'none':
this.fileAccessMode = 'read';
setIcon(fileAccessButton, "file-search");
fileAccessButton.title = "Toggle file access mode (Read Only)";
fileAccessButton.title = "Toggle open file access (Read Only)";
break;
case 'read':
this.fileAccessMode = 'write';
setIcon(fileAccessButton, "file-edit");
fileAccessButton.title = "Toggle file access mode (Read & Write)";
fileAccessButton.title = "Toggle open file access (Read & Write)";
break;
case 'write':
this.fileAccessMode = 'none';
setIcon(fileAccessButton, "file-x");
fileAccessButton.title = "Toggle file access mode (No Access)";
fileAccessButton.title = "Toggle open file access (No Access)";
break;
}
// Persist the updated mode to settings
this.setting.fileAccessMode = this.fileAccessMode;
await this.plugin.saveSettings();
});
let chatInput = inputRow.createEl("textarea", {
@@ -319,7 +341,7 @@ export class KhojChatView extends KhojPaneView {
attr: {
id: "khoj-transcribe",
class: "khoj-transcribe khoj-input-row-button clickable-icon ",
title: "Start Voice Chat (Ctrl+Alt+V)",
title: "Hold to Voice Chat (Ctrl+Alt+V)",
},
})
transcribe.addEventListener('mousedown', (event) => { this.startSpeechToText(event) });
@@ -943,7 +965,7 @@ export class KhojChatView extends KhojPaneView {
return learningMoments[Math.floor(Math.random() * learningMoments.length)];
}
async createNewConversation(agentSlug?: string) {
async createNewConversation(agentSlug?: string | null) {
let chatBodyEl = this.contentEl.getElementsByClassName("khoj-chat-body")[0] as HTMLElement;
chatBodyEl.innerHTML = "";
chatBodyEl.dataset.conversationId = "";
@@ -960,6 +982,7 @@ export class KhojChatView extends KhojPaneView {
try {
// Create a new conversation with or without an agent
let endpoint = `${this.setting.khojUrl}/api/chat/sessions`;
agentSlug = agentSlug || this.currentAgent;
if (agentSlug) {
endpoint += `?agent_slug=${encodeURIComponent(agentSlug)}`;
}
@@ -979,7 +1002,7 @@ export class KhojChatView extends KhojPaneView {
this.currentAgent = agentSlug || null;
// Update agent selector to reflect current agent
const agentSelect = this.contentEl.querySelector('.khoj-agent-select') as HTMLSelectElement;
const agentSelect = this.contentEl.querySelector('.khoj-header-agent-select') as HTMLSelectElement;
if (agentSelect) {
agentSelect.value = this.currentAgent || '';
}
@@ -1009,7 +1032,7 @@ export class KhojChatView extends KhojPaneView {
const newConversationButtonEl = newConversationEl.createEl("button");
newConversationButtonEl.classList.add("new-conversation-button");
newConversationButtonEl.classList.add("side-panel-button");
newConversationButtonEl.addEventListener('click', (_) => this.createNewConversation());
newConversationButtonEl.addEventListener('click', (_) => this.createNewConversation(this.currentAgent));
setIcon(newConversationButtonEl, "plus");
newConversationButtonEl.innerHTML += "New";
newConversationButtonEl.title = "New Conversation (Ctrl+Alt+N)";
@@ -1036,7 +1059,7 @@ export class KhojChatView extends KhojPaneView {
if (incomingConversationId == conversationId) {
conversationSessionEl.classList.add("selected-conversation");
}
const conversationTitle = conversation["slug"] || `New conversation 🌱`;
const conversationTitle = conversation["slug"].split("<SYSTEM>")[0].trim() || `New conversation 🌱`;
const conversationSessionTitleEl = conversationSessionEl.createDiv("conversation-session-title");
conversationSessionTitleEl.textContent = conversationTitle;
conversationSessionTitleEl.addEventListener('click', () => {
@@ -1189,7 +1212,7 @@ export class KhojChatView extends KhojPaneView {
chatUrl += `&conversation_id=${chatBodyEl.dataset.conversationId}`;
}
console.log("Fetching chat history from:", chatUrl);
console.debug("Fetching chat history from:", chatUrl);
try {
let response = await fetch(chatUrl, {
@@ -1198,7 +1221,7 @@ export class KhojChatView extends KhojPaneView {
});
let responseJson: any = await response.json();
console.log("Chat history response:", responseJson);
console.debug("Chat history response:", responseJson);
chatBodyEl.dataset.conversationId = responseJson.conversation_id;
@@ -1220,10 +1243,10 @@ export class KhojChatView extends KhojPaneView {
// Update current agent from conversation history
if (responseJson.response.agent?.slug) {
console.log("Found agent in conversation history:", responseJson.response.agent);
console.debug("Found agent in conversation history:", responseJson.response.agent);
this.currentAgent = responseJson.response.agent.slug;
// Update the agent selector if it exists
const agentSelect = this.contentEl.querySelector('.khoj-agent-select') as HTMLSelectElement;
const agentSelect = this.contentEl.querySelector('.khoj-header-agent-select') as HTMLSelectElement;
if (agentSelect && this.currentAgent) {
agentSelect.value = this.currentAgent;
console.log("Updated agent selector to:", this.currentAgent);
@@ -1351,18 +1374,25 @@ export class KhojChatView extends KhojPaneView {
if (this.fileAccessMode === 'write') {
const editBlocks = this.parseEditBlocks(this.chatMessageState.rawResponse);
// Check for errors and retry if needed
if (editBlocks.length > 0 && editBlocks[0].hasError && this.editRetryCount < this.maxEditRetries) {
await this.handleEditRetry(editBlocks[0]);
return;
}
// Reset retry count on success
this.editRetryCount = 0;
// Apply edits if there are any
if (editBlocks.length > 0) {
await this.applyEditBlocks(editBlocks);
const firstBlock = editBlocks[0];
if (firstBlock.hasError) {
// Only retry if we have remaining attempts; do NOT reset counter on failure
if (this.editRetryCount < this.maxEditRetries) {
await this.handleEditRetry(firstBlock);
return; // Wait for retry response
} else {
// Exhausted retries; surface error and do not attempt further automatic retries
console.warn('[Khoj] Max edit retries reached. Aborting further retries.');
}
} else {
// Successful parse => reset counter and apply edits
this.editRetryCount = 0;
await this.applyEditBlocks(editBlocks);
}
} else {
// No edit blocks => reset counter just in case
this.editRetryCount = 0;
}
}
@@ -1719,6 +1749,7 @@ export class KhojChatView extends KhojPaneView {
// Toggle recording
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive' || event.type === 'touchstart' || event.type === 'mousedown' || event.type === 'keydown') {
this.voiceChatActive = true;
navigator.mediaDevices
.getUserMedia({ audio: true })
?.then(handleRecording)
@@ -1726,6 +1757,7 @@ export class KhojChatView extends KhojPaneView {
this.flashStatusInChatInput("⛔️ Failed to access microphone");
});
} else if (this.mediaRecorder?.state === 'recording' || event.type === 'touchend' || event.type === 'touchcancel' || event.type === 'mouseup' || event.type === 'keyup') {
this.voiceChatActive = false;
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
this.mediaRecorder = undefined;
@@ -2402,7 +2434,7 @@ export class KhojChatView extends KhojPaneView {
// Add retry count
retryBadge.createSpan({
cls: "retry-count",
text: `Attempt ${this.editRetryCount}/3`
text: `Attempt ${this.editRetryCount}/${this.maxEditRetries}`
});
// Add error details as a tooltip
@@ -2417,7 +2449,7 @@ export class KhojChatView extends KhojPaneView {
retryBadge.scrollIntoView({ behavior: "smooth", block: "center" });
// Create a retry prompt for the LLM
const retryPrompt = `/general I noticed some issues with the edit block. Please fix the following and provide a corrected version (retry ${this.editRetryCount}/3):\n\n${errorDetails}\n\nPlease provide a new edit block that fixes these issues. Make sure to follow the exact format required.`;
const retryPrompt = `/general I noticed some issues with the edit block. Please fix the following and provide a corrected version (retry ${this.editRetryCount}/${this.maxEditRetries}):\n\n${errorDetails}\n\nPlease provide a new edit block that fixes these issues. Make sure to follow the exact format required.`;
// Send retry request without displaying the user message
await this.getChatResponse(retryPrompt, "", false, false);

View File

@@ -1,4 +1,4 @@
import { App, TFile } from 'obsidian';
import { App, MarkdownView, TFile } from 'obsidian';
import { diffWords } from 'diff';
/**
@@ -55,6 +55,7 @@ export class FileInteractions {
private app: App;
private readonly EDIT_BLOCK_START = '<khoj_edit>';
private readonly EDIT_BLOCK_END = '</khoj_edit>';
private readonly CONTEXT_FILES_LIMIT = 3;
/**
* Constructor for FileInteractions
@@ -65,6 +66,26 @@ export class FileInteractions {
this.app = app;
}
/**
* Get N open, recently viewed markdown files.
*/
private getRecentActiveMarkdownFiles(N: number): TFile[] {
const seen = new Set<string>();
const recentActiveFiles = this.app.workspace.getLeavesOfType('markdown')
.sort((a, b) => (b as any).activeTime - (a as any).activeTime) // Sort by leaf activeTime (note: undocumented prop)
.map(leaf => (leaf.view as MarkdownView)?.file)
// Dedupe by file path
.filter((file): file is TFile => {
if (!file || seen.has(file.path)) return false;
seen.add(file.path);
return true;
})
.slice(0, N);
console.log(`Using ${recentActiveFiles.length} recently viewed md files for context: ${recentActiveFiles.map(file => file.path).join(', ')}`);
return recentActiveFiles;
}
/**
* Gets the content of all open files
*
@@ -75,9 +96,9 @@ export class FileInteractions {
// Only proceed if we have read or write access
if (fileAccessMode === 'none') return '';
// Get all open markdown leaves
const leaves = this.app.workspace.getLeavesOfType('markdown');
if (leaves.length === 0) return '';
// Get recently viewed markdown files
const recentFiles = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT);
if (recentFiles.length === 0) return '';
// Instructions in write access mode
let editInstructions: string = '';
@@ -274,11 +295,7 @@ For context, the user is currently working on the following files:
`;
for (const leaf of leaves) {
const view = leaf.view as any;
const file = view?.file;
if (!file || file.extension !== 'md') continue;
for (const file of recentFiles) {
// Read file content
let fileContent: string;
try {
@@ -415,8 +432,16 @@ For context, the user is currently working on the following files:
}
// Try parse SEARCH/REPLACE format for complete edit blocks
// Regex: file_path\n<<<<<<< SEARCH\nsearch_content\n=======\nreplacement_content\n>>>>>>> REPLACE
const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE\s*$/;
// Supports empty SEARCH (new file / replace whole file) and empty REPLACE (deletion)
// Regex structure:
// file_path (group 1)
// <<<<<<< SEARCH literal marker
// search_content (group 2, can be empty)
// ======= divider
// replacement_content (group 3, can be empty => deletion)
// >>>>>>> REPLACE end marker
// Note: The trailing newline before the end marker is optional to allow zero-length replacement
const newFormatRegex = /^([^\n]+)\n<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n?>>>>>>> REPLACE\s*$/;
const newFormatMatch = newFormatRegex.exec(cleanContent);
let editData: EditBlock | null = null;
@@ -430,32 +455,25 @@ For context, the user is currently working on the following files:
// Validate required fields
let error: { type: 'missing_field' | 'invalid_format' | 'preprocessing' | 'unknown', message: string, details?: string } | null = null;
if (!editData) {
error = {
type: 'invalid_format',
message: 'Invalid edit block format',
details: 'The edit block does not match the expected format'
};
}
else if (!editData.file) {
if (editData && !editData.file) {
error = {
type: 'missing_field',
message: 'Missing "file" field in edit block',
details: 'The "file" field is required and should contain the target file name'
};
}
else if (editData.find === undefined || editData.find === null) {
else if (editData && (editData.find === undefined || editData.find === null)) {
error = {
type: 'missing_field',
message: 'Missing "find" field markers',
details: 'The "find" field is required and should contain the content to find in the file'
details: 'The "find" field is required. It should contain the content to find in the file or be empty for new files'
};
}
else if (!editData.replace) {
else if (editData && editData.replace === undefined) {
error = {
type: 'missing_field',
message: 'Missing "replace" field in edit block',
details: 'The "replace" field is required and should contain the replacement text'
details: 'The "replace" field is required. It should contain the content to replace or be empty to indicate deletion'
};
}
@@ -507,7 +525,7 @@ For context, the user is currently working on the following files:
}
if (!editData) {
console.error("No edit data parsed");
console.debug("No edit data parsed");
continue;
}
@@ -684,10 +702,8 @@ For context, the user is currently working on the following files:
// Track current content for each file as we apply edits
const currentFileContents = new Map<string, string>();
// Get all open markdown files
const files = this.app.workspace.getLeavesOfType('markdown')
.map(leaf => (leaf.view as any)?.file)
.filter(file => file && file.extension === 'md');
// Get recently viewed markdown file(s) to edit
const files = this.getRecentActiveMarkdownFiles(this.CONTEXT_FILES_LIMIT);
// Track success/failure for each edit
const editResults: { block: EditBlock, success: boolean, error?: string }[] = [];
@@ -883,6 +899,10 @@ For context, the user is currently working on the following files:
// Parse the block content
const { editData, cleanContent, error, inProgress } = this.parseEditBlock(content, isComplete);
if (!editData && !error) {
// If no edit data and no error, skip this block
continue;
}
// Escape content for HTML display
const diff = diffWords(editData?.find || '', editData?.replace || '');
@@ -898,7 +918,7 @@ For context, the user is currently working on the following files:
).join('').trim();
let htmlRender = '';
if (error || !editData) {
if (error) {
// Error block
console.error("Error parsing khoj-edit block:", error);
console.error("Content causing error:", content);
@@ -913,7 +933,7 @@ For context, the user is currently working on the following files:
<pre><code class="language-md error">${diffContent}</code></pre>
</div>
</details>`;
} else if (inProgress) {
} else if (editData && inProgress) {
// In-progress block
htmlRender = `<details class="khoj-edit-accordion in-progress">
<summary>📄 ${editData.file} <span class="khoj-edit-status">In Progress</span></summary>
@@ -921,7 +941,7 @@ For context, the user is currently working on the following files:
<pre><code class="language-md">${diffContent}</code></pre>
</div>
</details>`;
} else {
} else if (editData) {
// Success block
// Find the actual file that will be modified
const targetFile = this.findBestMatchingFile(editData.file, files);

View File

@@ -3,8 +3,8 @@ import { KhojSetting, KhojSettingTab, DEFAULT_SETTINGS } from 'src/settings'
import { KhojSearchModal } from 'src/search_modal'
import { KhojChatView } from 'src/chat_view'
import { KhojSimilarView } from 'src/similar_view'
import { updateContentIndex, canConnectToBackend, KhojView, jumpToPreviousView } from './utils';
import { KhojPaneView } from './pane_view';
import { updateContentIndex, canConnectToBackend, KhojView } from 'src/utils';
import { KhojPaneView } from 'src/pane_view';
export default class Khoj extends Plugin {
@@ -73,7 +73,7 @@ export default class Khoj extends Plugin {
this.activateView(KhojView.CHAT).then(() => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
chatView.toggleChatSessions(true);
chatView.toggleChatSessions();
}
});
}
@@ -88,8 +88,9 @@ export default class Khoj extends Plugin {
this.activateView(KhojView.CHAT).then(() => {
const chatView = this.app.workspace.getActiveViewOfType(KhojChatView);
if (chatView) {
// Trigger speech to text functionality
chatView.speechToText(new KeyboardEvent('keydown'));
// Toggle speech to text functionality
const toggleEvent = chatView.voiceChatActive ? 'keyup' : 'keydown';
chatView.speechToText(new KeyboardEvent(toggleEvent));
}
});
}
@@ -136,8 +137,8 @@ export default class Khoj extends Plugin {
});
// Register views
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this.settings));
this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this.settings));
this.registerView(KhojView.CHAT, (leaf) => new KhojChatView(leaf, this));
this.registerView(KhojView.SIMILAR, (leaf) => new KhojSimilarView(leaf, this));
// Create an icon in the left ribbon.
this.addRibbonIcon('message-circle', 'Khoj', (_: MouseEvent) => {

View File

@@ -1,14 +1,17 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { KhojSetting } from 'src/settings';
import { KhojView, populateHeaderPane } from './utils';
import Khoj from 'src/main';
export abstract class KhojPaneView extends ItemView {
setting: KhojSetting;
plugin: Khoj;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
super(leaf);
this.setting = setting;
this.setting = plugin.settings;
this.plugin = plugin;
// Register Modal Keybindings to send user message
// this.scope.register([], 'Enter', async () => { await this.chat() });

View File

@@ -16,6 +16,10 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
currentController: AbortController | null = null; // To cancel requests
isLoading: boolean = false;
loadingEl: HTMLElement;
private isFileFilterMode: boolean = false;
private fileSelected: string = "";
private allFiles: Array<{path: string, inVault: boolean}> = [];
private resultsTitle: HTMLDivElement;
constructor(app: App, setting: KhojSetting, find_similar_notes: boolean = false) {
super(app);
@@ -85,6 +89,46 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
// Set Placeholder Text for Modal
this.setPlaceholder('Search with Khoj...');
// Initialize allFiles with files in vault
this.allFiles = this.app.vault.getFiles().map(file => ({
path: file.path,
inVault: true
}));
// Update isFileFilterMode when input changes
this.inputEl.addEventListener('input', () => {
// Match file: at the end of input, with an optional unquoted partial path
const fileFilterMatch = this.inputEl.value.match(/file:([^"\s]*)$/);
if (fileFilterMatch) {
// Enter file filter mode when we see an unquoted file: token
this.isFileFilterMode = true;
} else {
// Exit file filter mode when input no longer ends with an unquoted file: token
this.isFileFilterMode = false;
this.fileSelected = "";
}
});
// Override selectSuggestion to prevent modal close during file filter selection
const originalSelectSuggestion = this.selectSuggestion.bind(this);
this.selectSuggestion = async (value: SearchResult & { inVault: boolean }, evt: MouseEvent | KeyboardEvent) => {
if (this.isFileFilterMode) {
// In file filter mode, handle selection without closing the modal
await this.onChooseSuggestion(value, evt);
} else {
// For normal search results, use the original behavior
originalSelectSuggestion(value, evt);
}
};
// Add title element
this.resultsTitle = createDiv();
this.resultsTitle.style.padding = "8px";
this.resultsTitle.style.fontWeight = "bold";
// Insert title before results container
this.resultContainerEl.parentElement?.insertBefore(this.resultsTitle, this.resultContainerEl);
}
// Check if the file exists in the vault
@@ -99,7 +143,30 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
}
async getSuggestions(query: string): Promise<SearchResult[]> {
// Do not show loading if the query is empty
// Check if we are in file filter mode and input matches file filter pattern
const fileFilterMatch = query.match(/file:([^"\s]*)$/);
if (this.isFileFilterMode && fileFilterMatch) {
const partialPath = fileFilterMatch[1] || '';
// Update title for file filter mode
this.resultsTitle.setText("Select a file:");
// Return filtered file suggestions
return this.allFiles
.filter(file => file.path.toLowerCase().includes(partialPath.toLowerCase().trim()))
.map(file => ({
entry: file.path,
file: file.path,
inVault: file.inVault
}));
}
// Update title for search results
if (query.trim()) {
this.resultsTitle.setText("Search results:");
} else {
this.resultsTitle.setText("");
}
// If not in file filter mode, continue with normal search
if (!query.trim()) {
this.isLoading = false;
this.updateLoadingState();
@@ -138,22 +205,29 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
const data = await response.json();
// Parse search results
// Parse search results and update allFiles with any new non-vault files
let results = data
.filter((result: any) =>
!this.find_similar_notes || !result.additional.file.endsWith(this.app.workspace.getActiveFile()?.path)
)
.map((result: any) => {
const isInVault = this.isFileInVault(result.additional.file);
// Add new non-vault files to allFiles if they don't exist
if (!this.allFiles.some(file => file.path === result.additional.file)) {
this.allFiles.push({
path: result.additional.file,
inVault: isInVault
});
}
return {
entry: result.entry,
file: result.additional.file,
inVault: this.isFileInVault(result.additional.file)
inVault: isInVault
} as SearchResult & { inVault: boolean };
})
.sort((a: SearchResult & { inVault: boolean }, b: SearchResult & { inVault: boolean }) => {
if (a.inVault === b.inVault) return 0;
return a.inVault ? -1 : 1;
});
.sort((a: SearchResult & { inVault: boolean }, b: SearchResult & { inVault: boolean }) => Number(b.inVault) - Number(a.inVault));
this.query = query;
@@ -203,6 +277,15 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
}
async renderSuggestion(result: SearchResult & { inVault: boolean }, el: HTMLElement) {
if (this.isFileFilterMode) {
// Render file suggestions
el.createEl("div", {
text: result.entry,
cls: "khoj-file-suggestion"
});
return;
}
// Max number of lines to render
let lines_to_render = 8;
@@ -251,6 +334,20 @@ export class KhojSearchModal extends SuggestModal<SearchResult> {
}
async onChooseSuggestion(result: SearchResult & { inVault: boolean }, _: MouseEvent | KeyboardEvent) {
if (this.isFileFilterMode) {
// When a file suggestion is selected, append it to the current input
const currentValue = this.inputEl.value;
const beforeFile = currentValue.substring(0, currentValue.lastIndexOf('file:'));
this.inputEl.value = `${beforeFile}file:"${result.entry}"`;
// Set fileSelected to the selected file
this.fileSelected = result.entry;
// Reset isFileFilterMode when a file is selected
this.isFileFilterMode = false;
// Trigger input event to refresh suggestions
this.inputEl.dispatchEvent(new Event('input'));
return;
}
// Only open files that are in the vault
if (!result.inVault) {
new Notice("This file is not in your vault");

View File

@@ -36,8 +36,10 @@ export interface KhojSetting {
syncFileType: SyncFileTypes;
userInfo: UserInfo | null;
syncFolders: string[];
excludeFolders: string[];
syncInterval: number;
autoVoiceResponse: boolean;
fileAccessMode: 'none' | 'read' | 'write';
selectedChatModelId: string | null; // Mirrors server's selected_chat_model_config
availableChatModels: ModelOption[];
}
@@ -56,8 +58,10 @@ export const DEFAULT_SETTINGS: KhojSetting = {
},
userInfo: null,
syncFolders: [],
excludeFolders: [],
syncInterval: 60,
autoVoiceResponse: true,
fileAccessMode: 'read',
selectedChatModelId: null, // Will be populated from server
availableChatModels: [],
}
@@ -65,6 +69,8 @@ export const DEFAULT_SETTINGS: KhojSetting = {
export class KhojSettingTab extends PluginSettingTab {
plugin: Khoj;
private chatModelSetting: Setting | null = null;
private storageProgressEl: HTMLProgressElement | null = null;
private storageProgressText: HTMLSpanElement | null = null;
constructor(app: App, plugin: Khoj) {
super(app, plugin);
@@ -225,6 +231,7 @@ export class KhojSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.syncFileType.markdown = value;
await this.plugin.saveSettings();
this.refreshStorageDisplay();
}));
// Add setting to sync images
@@ -236,6 +243,7 @@ export class KhojSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.syncFileType.images = value;
await this.plugin.saveSettings();
this.refreshStorageDisplay();
}));
// Add setting to sync PDFs
@@ -247,6 +255,7 @@ export class KhojSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.syncFileType.pdf = value;
await this.plugin.saveSettings();
this.refreshStorageDisplay();
}));
// Add setting for sync interval
@@ -271,27 +280,56 @@ export class KhojSettingTab extends PluginSettingTab {
this.plugin.restartSyncTimer();
}));
// Add setting to manage sync folders
const syncFoldersContainer = containerEl.createDiv('sync-folders-container');
new Setting(syncFoldersContainer)
.setName('Sync Folders')
.setDesc('Specify folders to sync (leave empty to sync entire vault)')
// Add setting to manage include folders
const includeFoldersContainer = containerEl.createDiv('include-folders-container');
new Setting(includeFoldersContainer)
.setName('Include Folders')
.setDesc('Folders to sync (leave empty to sync entire vault)')
.addButton(button => button
.setButtonText('Add Folder')
.onClick(() => {
const modal = new FolderSuggestModal(this.app, (folder: string) => {
const modal = new FolderSuggestModal(this.app, async (folder: string) => {
if (!this.plugin.settings.syncFolders.includes(folder)) {
this.plugin.settings.syncFolders.push(folder);
this.plugin.saveSettings();
this.updateFolderList(folderListEl);
await this.plugin.saveSettings();
this.updateIncludeFolderList(includeFolderListEl);
this.refreshStorageDisplay();
}
});
modal.open();
}));
// Create a list to display selected folders
const folderListEl = syncFoldersContainer.createDiv('folder-list');
this.updateFolderList(folderListEl);
// Create a list to display selected include folders
const includeFolderListEl = includeFoldersContainer.createDiv('folder-list');
this.updateIncludeFolderList(includeFolderListEl);
// Add setting to manage exclude folders
const excludeFoldersContainer = containerEl.createDiv('exclude-folders-container');
new Setting(excludeFoldersContainer)
.setName('Exclude Folders')
.setDesc('Folders to exclude from sync (takes precedence over includes)')
.addButton(button => button
.setButtonText('Add Folder')
.onClick(() => {
const modal = new FolderSuggestModal(this.app, async (folder: string) => {
// Don't allow excluding root folder
if (folder === '') {
new Notice('Cannot exclude the root folder');
return;
}
if (!this.plugin.settings.excludeFolders.includes(folder)) {
this.plugin.settings.excludeFolders.push(folder);
await this.plugin.saveSettings();
this.updateExcludeFolderList(excludeFolderListEl);
this.refreshStorageDisplay();
}
});
modal.open();
}));
// Create a list to display selected exclude folders
const excludeFolderListEl = excludeFoldersContainer.createDiv('folder-list');
this.updateExcludeFolderList(excludeFolderListEl);
let indexVaultSetting = new Setting(containerEl);
indexVaultSetting
@@ -306,7 +344,7 @@ export class KhojSettingTab extends PluginSettingTab {
button.removeCta();
indexVaultSetting = indexVaultSetting.setDisabled(true);
// Show indicator for indexing in progress
// Show indicator for indexing in progress (animated text)
const progress_indicator = window.setInterval(() => {
if (button.buttonEl.innerText === 'Updating 🌑') {
button.setButtonText('Updating 🌘');
@@ -328,17 +366,79 @@ export class KhojSettingTab extends PluginSettingTab {
}, 300);
this.plugin.registerInterval(progress_indicator);
this.plugin.settings.lastSync = await updateContentIndex(
this.app.vault, this.plugin.settings, this.plugin.settings.lastSync, true, true
);
// Obtain sync progress elements by id (created below)
const syncProgressEl = document.getElementById('khoj-sync-progress') as HTMLProgressElement | null;
const syncProgressText = document.getElementById('khoj-sync-progress-text') as HTMLElement | null;
// Reset button once index is updated
window.clearInterval(progress_indicator);
button.setButtonText('Update');
button.setCta();
indexVaultSetting = indexVaultSetting.setDisabled(false);
if (syncProgressEl && syncProgressText) {
syncProgressEl.style.display = '';
syncProgressText.style.display = '';
syncProgressText.textContent = 'Preparing files...';
syncProgressEl.value = 0;
syncProgressEl.max = 1;
}
const onProgress = (progress: { processed: number, total: number }) => {
const el = document.getElementById('khoj-sync-progress') as HTMLProgressElement | null;
const txt = document.getElementById('khoj-sync-progress-text') as HTMLElement | null;
if (!el || !txt) return;
el.max = Math.max(progress.total, 1);
el.value = Math.min(progress.processed, el.max);
txt.textContent = `Syncing... ${progress.processed} / ${progress.total} files`;
};
try {
this.plugin.settings.lastSync = await updateContentIndex(
this.app.vault, this.plugin.settings, this.plugin.settings.lastSync, true, true, onProgress
);
} finally {
// Cleanup: hide sync progress UI
const el = document.getElementById('khoj-sync-progress') as HTMLProgressElement | null;
const txt = document.getElementById('khoj-sync-progress-text') as HTMLElement | null;
if (el) el.style.display = 'none';
if (txt) txt.style.display = 'none';
this.refreshStorageDisplay();
// Reset button state
window.clearInterval(progress_indicator);
button.setButtonText('Update');
button.setCta();
indexVaultSetting = indexVaultSetting.setDisabled(false);
}
})
);
// Estimated Cloud Storage (client-side)
const storageSetting = new Setting(containerEl)
.setName('Estimated Cloud Storage')
.setDesc('Estimated storage usage based on files configured for sync. This is a client-side estimation.')
.then(() => { });
// Create custom elements: progress and text for storage estimation
this.storageProgressEl = document.createElement('progress');
this.storageProgressEl.value = 0;
this.storageProgressEl.max = 1;
this.storageProgressEl.style.width = '100%';
this.storageProgressText = document.createElement('span');
this.storageProgressText.textContent = 'Calculating...';
storageSetting.descEl.appendChild(this.storageProgressEl);
storageSetting.descEl.appendChild(this.storageProgressText);
// Create progress bar for Force Sync operation (hidden by default)
const syncProgressEl = document.createElement('progress');
syncProgressEl.id = 'khoj-sync-progress';
syncProgressEl.value = 0;
syncProgressEl.max = 1;
syncProgressEl.style.width = '100%';
syncProgressEl.style.display = 'none';
const syncProgressText = document.createElement('span');
syncProgressText.id = 'khoj-sync-progress-text';
syncProgressText.textContent = '';
syncProgressText.style.display = 'none';
storageSetting.descEl.appendChild(syncProgressEl);
storageSetting.descEl.appendChild(syncProgressText);
// Call initial update
this.refreshStorageDisplay();
}
private connectStatusIcon() {
@@ -350,6 +450,28 @@ export class KhojSettingTab extends PluginSettingTab {
return '🔴';
}
private async refreshStorageDisplay() {
if (!this.storageProgressEl || !this.storageProgressText) return;
// Show calculating state
this.storageProgressEl.removeAttribute('value');
this.storageProgressText.textContent = 'Calculating...';
try {
const { calculateVaultSyncMetrics } = await import('./utils');
const metrics = await calculateVaultSyncMetrics(this.app.vault, this.plugin.settings);
const usedMB = (metrics.usedBytes / (1024 * 1024));
const totalMB = (metrics.totalBytes / (1024 * 1024));
const usedStr = `${usedMB.toFixed(1)} MB`;
const totalStr = `${totalMB.toFixed(0)} MB`;
this.storageProgressEl.value = metrics.usedBytes;
this.storageProgressEl.max = metrics.totalBytes;
this.storageProgressText.textContent = `${usedStr} / ${totalStr}`;
} catch (err) {
console.error('Khoj: Failed to update storage display', err);
this.storageProgressText.textContent = 'Estimation unavailable';
}
}
private async refreshModelsAndServerPreference() {
let serverSelectedModelId: string | null = null;
if (this.plugin.settings.connectedToBackend) {
@@ -439,19 +561,54 @@ export class KhojSettingTab extends PluginSettingTab {
});
}
// Helper method to update the folder list display
private updateFolderList(containerEl: HTMLElement) {
// Helper method to update the include folder list display
private updateIncludeFolderList(containerEl: HTMLElement) {
this.updateFolderList(
containerEl,
this.plugin.settings.syncFolders,
'Including entire vault',
async (folder) => {
this.plugin.settings.syncFolders = this.plugin.settings.syncFolders.filter(f => f !== folder);
await this.plugin.saveSettings();
this.updateIncludeFolderList(containerEl);
this.refreshStorageDisplay();
}
);
}
// Helper method to update the exclude folder list display
private updateExcludeFolderList(containerEl: HTMLElement) {
this.updateFolderList(
containerEl,
this.plugin.settings.excludeFolders,
'No folders excluded',
async (folder) => {
this.plugin.settings.excludeFolders = this.plugin.settings.excludeFolders.filter(f => f !== folder);
await this.plugin.saveSettings();
this.updateExcludeFolderList(containerEl);
this.refreshStorageDisplay();
}
);
}
// Shared helper to render a folder list with remove buttons
private updateFolderList(
containerEl: HTMLElement,
folders: string[],
emptyText: string,
onRemove: (folder: string) => void
) {
containerEl.empty();
if (this.plugin.settings.syncFolders.length === 0) {
if (folders.length === 0) {
containerEl.createEl('div', {
text: 'Syncing entire vault',
text: emptyText,
cls: 'folder-list-empty'
});
return;
}
const list = containerEl.createEl('ul', { cls: 'folder-list' });
this.plugin.settings.syncFolders.forEach(folder => {
folders.forEach(folder => {
const item = list.createEl('li', { cls: 'folder-list-item' });
item.createSpan({ text: folder });
@@ -459,11 +616,7 @@ export class KhojSettingTab extends PluginSettingTab {
cls: 'folder-list-remove',
text: '×'
});
removeButton.addEventListener('click', async () => {
this.plugin.settings.syncFolders = this.plugin.settings.syncFolders.filter(f => f !== folder);
await this.plugin.saveSettings();
this.updateFolderList(containerEl);
});
removeButton.addEventListener('click', () => onRemove(folder));
});
}
}

View File

@@ -1,7 +1,7 @@
import { WorkspaceLeaf, TFile, MarkdownRenderer, Notice, setIcon } from 'obsidian';
import { KhojSetting } from 'src/settings';
import { KhojPaneView } from 'src/pane_view';
import { KhojView, getLinkToEntry, supportedBinaryFileTypes } from 'src/utils';
import Khoj from 'src/main';
export interface SimilarResult {
entry: string;
@@ -11,7 +11,6 @@ export interface SimilarResult {
export class KhojSimilarView extends KhojPaneView {
static iconName: string = "search";
setting: KhojSetting;
currentController: AbortController | null = null;
isLoading: boolean = false;
loadingEl: HTMLElement;
@@ -21,9 +20,8 @@ export class KhojSimilarView extends KhojPaneView {
fileWatcher: any;
component: any;
constructor(leaf: WorkspaceLeaf, setting: KhojSetting) {
super(leaf, setting);
this.setting = setting;
constructor(leaf: WorkspaceLeaf, plugin: Khoj) {
super(leaf, plugin);
this.component = this;
}

View File

@@ -1,5 +1,6 @@
import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor, App, WorkspaceLeaf } from 'obsidian';
import { FileSystemAdapter, Notice, Vault, Modal, TFile, request, setIcon, Editor, WorkspaceLeaf } from 'obsidian';
import { KhojSetting, ModelOption, ServerUserConfig, UserInfo } from 'src/settings'
import { deleteContentByType, uploadContentBatch } from './api';
import { KhojSearchModal } from './search_modal';
export function getVaultAbsolutePath(vault: Vault): string {
@@ -60,9 +61,7 @@ export const supportedImageFilesTypes = fileTypeToExtension.image;
export const supportedBinaryFileTypes = fileTypeToExtension.pdf.concat(supportedImageFilesTypes);
export const supportedFileTypes = fileTypeToExtension.markdown.concat(supportedBinaryFileTypes);
export async function updateContentIndex(vault: Vault, setting: KhojSetting, lastSync: Map<TFile, number>, regenerate: boolean = false, userTriggered: boolean = false): Promise<Map<TFile, number>> {
// Get all markdown, pdf files in the vault
console.log(`Khoj: Updating Khoj content index...`)
export function getFilesToSync(vault: Vault, setting: KhojSetting): TFile[] {
const files = vault.getFiles()
// Filter supported file types for syncing
.filter(file => supportedFileTypes.includes(file.extension))
@@ -73,7 +72,7 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las
if (fileTypeToExtension.image.includes(file.extension)) return setting.syncFileType.images;
return false;
})
// Filter files based on specified folders
// Filter in included folders
.filter(file => {
// If no folders are specified, sync all files
if (setting.syncFolders.length === 0) return true;
@@ -81,17 +80,61 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las
return setting.syncFolders.some(folder =>
file.path.startsWith(folder + '/') || file.path === folder
);
})
// Filter out excluded folders
.filter(file => {
// If no folders are excluded, include all files
if (setting.excludeFolders.length === 0) return true;
// Exclude files in any of the excluded folders
return !setting.excludeFolders.some(folder =>
file.path.startsWith(folder + '/') || file.path === folder
);
})
// Sort files by type: markdown > pdf > image
.sort((a, b) => {
const typeOrder: (keyof typeof fileTypeToExtension)[] = ['markdown', 'pdf', 'image'];
const aType = typeOrder.findIndex(type => fileTypeToExtension[type].includes(a.extension));
const bType = typeOrder.findIndex(type => fileTypeToExtension[type].includes(b.extension));
return aType - bType;
});
return files;
}
export async function updateContentIndex(
vault: Vault,
setting: KhojSetting,
lastSync: Map<TFile, number>,
regenerate: boolean = false,
userTriggered: boolean = false,
onProgress?: (progress: { processed: number, total: number }) => void
): Promise<Map<TFile, number>> {
// Get all markdown, pdf files in the vault
console.log(`Khoj: Updating Khoj content index...`);
const files = getFilesToSync(vault, setting);
console.log(`Khoj: Found ${files.length} eligible files in vault`);
let countOfFilesToIndex = 0;
let countOfFilesToDelete = 0;
lastSync = lastSync.size > 0 ? lastSync : new Map<TFile, number>();
// Add all files to index as multipart form data
let fileData = [];
let currentBatchSize = 0;
// Count files that need indexing (modified since last sync or regenerating)
const filesToSync = regenerate
? files
: files.filter(file => file.stat.mtime >= (lastSync.get(file) ?? 0));
// Show notice with file counts when user triggers sync
if (userTriggered) {
new Notice(`🔄 Syncing ${filesToSync.length} of ${files.length} files to Khoj...`);
}
console.log(`Khoj: ${filesToSync.length} files to sync (${files.length} total eligible)`);
// Add all files to index as multipart form data, batched by size, item count
const MAX_BATCH_SIZE = 10 * 1024 * 1024; // 10MB max batch size
let currentBatch = [];
const MAX_BATCH_ITEMS = 50; // Max 50 items per batch
let fileData: { blob: Blob, path: string }[][] = [];
let currentBatch: { blob: Blob, path: string }[] = [];
let currentBatchSize = 0;
for (const file of files) {
// Only push files that have been modified since last sync if not regenerating
@@ -105,9 +148,8 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las
const fileContent = encoding == 'binary' ? await vault.readBinary(file) : await vault.read(file);
const fileItem = { blob: new Blob([fileContent], { type: mimeType }), path: file.path };
// Check if adding this file would exceed batch size
const fileSize = (typeof fileContent === 'string') ? new Blob([fileContent]).size : fileContent.byteLength;
if (currentBatchSize + fileSize > MAX_BATCH_SIZE && currentBatch.length > 0) {
if ((currentBatchSize + fileSize > MAX_BATCH_SIZE || currentBatch.length >= MAX_BATCH_ITEMS) && currentBatch.length > 0) {
fileData.push(currentBatch);
currentBatch = [];
currentBatchSize = 0;
@@ -117,12 +159,12 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las
currentBatchSize += fileSize;
}
// Add any previously synced files to be deleted to final batch
// Add files to delete (previously synced but no longer in vault) to final batch
let filesToDelete: TFile[] = [];
for (const lastSyncedFile of lastSync.keys()) {
if (!files.includes(lastSyncedFile)) {
countOfFilesToDelete++;
let fileObj = new Blob([""], { type: filenameToMimeType(lastSyncedFile) });
const fileObj = new Blob([""], { type: filenameToMimeType(lastSyncedFile) });
currentBatch.push({ blob: fileObj, path: lastSyncedFile.path });
filesToDelete.push(lastSyncedFile);
}
@@ -134,86 +176,51 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las
}
// Delete all files of enabled content types first if regenerating
let error_message = null;
const contentTypesToDelete = [];
let error_message: string | null = null;
if (regenerate) {
// Mark content types to delete based on user sync file type settings
const contentTypesToDelete: string[] = [];
if (setting.syncFileType.markdown) contentTypesToDelete.push('markdown');
if (setting.syncFileType.pdf) contentTypesToDelete.push('pdf');
if (setting.syncFileType.images) contentTypesToDelete.push('image');
}
for (const contentType of contentTypesToDelete) {
const response = await fetch(`${setting.khojUrl}/api/content/type/${contentType}?client=obsidian`, {
method: "DELETE",
headers: {
'Authorization': `Bearer ${setting.khojApiKey}`,
try {
for (const contentType of contentTypesToDelete) {
await deleteContentByType(setting.khojUrl, setting.khojApiKey, contentType);
}
});
if (!response.ok) {
} catch (err) {
console.error('Khoj: Error deleting content types:', err);
error_message = "❗Failed to clear existing content index";
fileData = [];
}
}
// Iterate through all indexable files in vault, 10Mb batch at a time
// Upload files in batches
let responses: string[] = [];
let processedFiles = 0;
const totalFiles = fileData.reduce((sum, batch) => sum + batch.length, 0);
// Report initial progress with total count before uploading
if (onProgress) {
onProgress({ processed: 0, total: totalFiles });
}
for (const batch of fileData) {
// Create multipart form data with all files in batch
const formData = new FormData();
batch.forEach(fileItem => { formData.append('files', fileItem.blob, fileItem.path) });
// Call Khoj backend to sync index with updated files in vault
const method = regenerate ? "PUT" : "PATCH";
const response = await fetch(`${setting.khojUrl}/api/content?client=obsidian`, {
method: method,
headers: {
'Authorization': `Bearer ${setting.khojApiKey}`,
},
body: formData,
});
if (!response.ok) {
if (response.status === 429) {
let response_text = await response.text();
if (response_text.includes("Too much data")) {
const errorFragment = document.createDocumentFragment();
errorFragment.appendChild(document.createTextNode("❗Exceeded data sync limits. To resolve this either:"));
const bulletList = document.createElement('ul');
const limitFilesItem = document.createElement('li');
const settingsPrefixText = document.createTextNode("Limit files to sync from ");
const settingsLink = document.createElement('a');
settingsLink.textContent = "Khoj settings";
settingsLink.href = "#";
settingsLink.addEventListener('click', (e) => {
e.preventDefault();
openKhojPluginSettings();
});
limitFilesItem.appendChild(settingsPrefixText);
limitFilesItem.appendChild(settingsLink);
bulletList.appendChild(limitFilesItem);
const upgradeItem = document.createElement('li');
const upgradeLink = document.createElement('a');
upgradeLink.href = `${setting.khojUrl}/settings#subscription`;
upgradeLink.textContent = 'Upgrade your subscription';
upgradeLink.target = '_blank';
upgradeItem.appendChild(upgradeLink);
bulletList.appendChild(upgradeItem);
errorFragment.appendChild(bulletList);
error_message = errorFragment;
} else {
error_message = `Failed to sync your content with Khoj server. Requests were throttled. Upgrade your subscription or try again later.`;
}
break;
} else if (response.status === 404) {
error_message = `Could not connect to Khoj server. Ensure you can connect to it.`;
break;
} else {
error_message = `Failed to sync all your content with Khoj server. Raise issue on Khoj Discord or Github\nError: ${response.statusText}`;
try {
const resultText = await uploadContentBatch(setting.khojUrl, setting.khojApiKey, batch);
responses.push(resultText);
processedFiles += batch.length;
if (onProgress) {
onProgress({ processed: processedFiles, total: totalFiles });
}
} else {
responses.push(await response.text());
} catch (err: any) {
console.error('Khoj: Failed to upload batch:', err);
if (err.message?.includes('429')) {
error_message = `Requests were throttled. Upgrade your subscription or try again later.`;
} else {
error_message = `Failed to sync content with Khoj server. Error: ${err.message ?? String(err)}`;
}
break;
}
}
@@ -233,8 +240,9 @@ export async function updateContentIndex(vault: Vault, setting: KhojSetting, las
if (error_message) {
new Notice(error_message);
} else {
if (userTriggered) new Notice('✅ Updated Khoj index.');
console.log(`✅ Refreshed Khoj content index. Updated: ${countOfFilesToIndex} files, Deleted: ${countOfFilesToDelete} files.`);
const summary = `Updated ${countOfFilesToIndex}, deleted ${countOfFilesToDelete} files`;
if (userTriggered) new Notice(`${summary}`);
console.log(`✅ Refreshed Khoj content index. ${summary}.`);
}
return lastSync;
@@ -606,6 +614,44 @@ export function getLinkToEntry(sourceFiles: TFile[], chosenFile: string, chosenE
}
}
/**
* Calculate estimated vault sync metrics (used and total bytes).
* This is a client-side estimation based on the configured sync file types and folders.
* The storage limit is determined from the backend-provided `setting.userInfo?.is_active` flag:
* - if true => premium limit (500 MB)
* - otherwise => free limit (10 MB)
* This avoids client-side heuristics and relies on server-provided user info.
*/
export async function calculateVaultSyncMetrics(vault: Vault, setting: KhojSetting): Promise<{ usedBytes: number, totalBytes: number }> {
try {
const files = getFilesToSync(vault, setting);
const usedBytes = files.reduce((acc, file) => acc + (file.stat?.size ?? 0), 0);
// Default to free plan limit
const FREE_LIMIT = 10 * 1024 * 1024; // 10 MB
const PAID_LIMIT = 500 * 1024 * 1024; // 500 MB
let totalBytes = FREE_LIMIT;
// Determine plan from backend-provided user info. Use FREE_LIMIT as default when info missing.
try {
if (setting.userInfo && setting.userInfo.is_active === true) {
totalBytes = PAID_LIMIT;
} else {
totalBytes = FREE_LIMIT;
}
} catch (err) {
// Defensive: on any unexpected error, fall back to free limit
console.warn('Khoj: Error reading userInfo.is_active, defaulting to free limit', err);
totalBytes = FREE_LIMIT;
}
return { usedBytes, totalBytes };
} catch (err) {
console.error('Khoj: Error calculating vault sync metrics:', err);
return { usedBytes: 0, totalBytes: 10 * 1024 * 1024 };
}
}
export async function fetchChatModels(settings: KhojSetting): Promise<ModelOption[]> {
if (!settings.connectedToBackend || !settings.khojUrl) {
return [];

View File

@@ -1300,6 +1300,11 @@ img.copy-icon {
}
}
.khoj-file-suggestion {
padding: 8px;
color: var(--text-muted);
}
.khoj-similar-message {
text-align: center;
padding: 20px;

View File

@@ -136,5 +136,33 @@
"1.42.5": "0.15.0",
"1.42.6": "0.15.0",
"1.42.7": "0.15.0",
"1.42.8": "0.15.0"
"1.42.8": "0.15.0",
"2.0.0-beta.1": "0.15.0",
"2.0.0-beta.2": "0.15.0",
"2.0.0-beta.3": "0.15.0",
"2.0.0-beta.4": "0.15.0",
"2.0.0-beta.5": "0.15.0",
"2.0.0-beta.6": "0.15.0",
"2.0.0-beta.7": "0.15.0",
"2.0.0-beta.8": "0.15.0",
"2.0.0-beta.9": "0.15.0",
"2.0.0-beta.10": "0.15.0",
"2.0.0-beta.11": "0.15.0",
"2.0.0-beta.12": "0.15.0",
"2.0.0-beta.13": "0.15.0",
"2.0.0-beta.14": "0.15.0",
"2.0.0-beta.15": "0.15.0",
"2.0.0-beta.16": "0.15.0",
"2.0.0-beta.17": "0.15.0",
"2.0.0-beta.18": "0.15.0",
"2.0.0-beta.19": "0.15.0",
"2.0.0-beta.20": "0.15.0",
"2.0.0-beta.21": "0.15.0",
"2.0.0-beta.22": "0.15.0",
"2.0.0-beta.23": "0.15.0",
"2.0.0-beta.24": "0.15.0",
"2.0.0-beta.25": "0.15.0",
"2.0.0-beta.26": "0.15.0",
"2.0.0-beta.27": "0.15.0",
"2.0.0-beta.28": "0.15.0"
}

View File

@@ -2,56 +2,94 @@
# yarn lockfile v1
"@asamuzakjp/css-color@^3.1.2":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz#cc42f5b85c593f79f1fa4f25d2b9b321e61d1794"
integrity sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==
"@acemir/cssom@^0.9.31":
version "0.9.31"
resolved "https://registry.yarnpkg.com/@acemir/cssom/-/cssom-0.9.31.tgz#bd5337d290fb8be2ac18391f37386bc53778b0bc"
integrity sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==
"@asamuzakjp/css-color@^5.0.1":
version "5.0.1"
resolved "https://registry.yarnpkg.com/@asamuzakjp/css-color/-/css-color-5.0.1.tgz#3b9462a9b52f3c6680a0945a3d0851881017550f"
integrity sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==
dependencies:
"@csstools/css-calc" "^2.1.3"
"@csstools/css-color-parser" "^3.0.9"
"@csstools/css-parser-algorithms" "^3.0.4"
"@csstools/css-tokenizer" "^3.0.3"
lru-cache "^10.4.3"
"@csstools/css-calc" "^3.1.1"
"@csstools/css-color-parser" "^4.0.2"
"@csstools/css-parser-algorithms" "^4.0.0"
"@csstools/css-tokenizer" "^4.0.0"
lru-cache "^11.2.6"
"@csstools/color-helpers@^5.0.2":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.0.2.tgz#82592c9a7c2b83c293d9161894e2a6471feb97b8"
integrity sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==
"@csstools/css-calc@^2.1.3":
version "2.1.3"
resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.3.tgz#6f68affcb569a86b91965e8622d644be35a08423"
integrity sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==
"@csstools/css-color-parser@^3.0.9":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz#8d81b77d6f211495b5100ec4cad4c8828de49f6b"
integrity sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==
"@asamuzakjp/dom-selector@^6.8.1":
version "6.8.1"
resolved "https://registry.yarnpkg.com/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz#39b20993672b106f7cd9a3a9a465212e87e0bfd1"
integrity sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==
dependencies:
"@csstools/color-helpers" "^5.0.2"
"@csstools/css-calc" "^2.1.3"
"@asamuzakjp/nwsapi" "^2.3.9"
bidi-js "^1.0.3"
css-tree "^3.1.0"
is-potential-custom-element-name "^1.0.1"
lru-cache "^11.2.6"
"@csstools/css-parser-algorithms@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz#74426e93bd1c4dcab3e441f5cc7ba4fb35d94356"
integrity sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==
"@asamuzakjp/nwsapi@^2.3.9":
version "2.3.9"
resolved "https://registry.yarnpkg.com/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz#ad5549322dfe9d153d4b4dd6f7ff2ae234b06e24"
integrity sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==
"@csstools/css-tokenizer@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz#a5502c8539265fecbd873c1e395a890339f119c2"
integrity sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==
"@bramus/specificity@^2.4.2":
version "2.4.2"
resolved "https://registry.yarnpkg.com/@bramus/specificity/-/specificity-2.4.2.tgz#aa8db8eb173fdee7324f82284833106adeecc648"
integrity sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==
dependencies:
css-tree "^3.0.0"
"@csstools/color-helpers@^6.0.2":
version "6.0.2"
resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-6.0.2.tgz#82c59fd30649cf0b4d3c82160489748666e6550b"
integrity sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==
"@csstools/css-calc@^3.1.1":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-3.1.1.tgz#78b494996dac41a02797dcca18ac3b46d25b3fd7"
integrity sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==
"@csstools/css-color-parser@^4.0.2":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz#c27e03a3770d0352db92d668d6dde427a37859e5"
integrity sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==
dependencies:
"@csstools/color-helpers" "^6.0.2"
"@csstools/css-calc" "^3.1.1"
"@csstools/css-parser-algorithms@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz#e1c65dc09378b42f26a111fca7f7075fc2c26164"
integrity sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==
"@csstools/css-syntax-patches-for-csstree@^1.0.28":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz#ce4c9a0cbe30590491fcd5c03fe6426d22ba89e4"
integrity sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==
"@csstools/css-tokenizer@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f"
integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==
"@eslint-community/eslint-utils@^4.4.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"
integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
version "4.9.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595"
integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.10.0":
version "4.12.1"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0"
integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==
version "4.12.2"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@exodus/bytes@^1.11.0", "@exodus/bytes@^1.6.0":
version "1.15.0"
resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.0.tgz#54479e0f406cbad024d6fe1c3190ecca4468df3b"
integrity sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -89,9 +127,9 @@
dompurify "*"
"@types/estree@*":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
version "1.0.8"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/node@^16.11.6":
version "16.18.126"
@@ -192,9 +230,9 @@
eslint-visitor-keys "^3.4.3"
agent-base@^7.1.0, agent-base@^7.1.2:
version "7.1.3"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.3.tgz#29435eb821bc4194633a5b89e5bc4703bafc25a1"
integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==
version "7.1.4"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8"
integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==
array-union@^2.1.0:
version "2.1.0"
@@ -206,10 +244,17 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
brace-expansion@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
bidi-js@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/bidi-js/-/bidi-js-1.0.3.tgz#6f8bcf3c877c4d9220ddf49b9bb6930c88f877d2"
integrity sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==
dependencies:
require-from-string "^2.0.2"
brace-expansion@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
dependencies:
balanced-match "^1.0.0"
@@ -225,38 +270,48 @@ builtin-modules@3.3.0:
resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
cssstyle@^4.2.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.3.1.tgz#68a3c9f5a70aa97d5a6ebecc9805e511fc022eb8"
integrity sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==
css-tree@^3.0.0, css-tree@^3.1.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-3.2.1.tgz#86cac7011561272b30e6b1e042ba6ce047aa7518"
integrity sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==
dependencies:
"@asamuzakjp/css-color" "^3.1.2"
rrweb-cssom "^0.8.0"
mdn-data "2.27.1"
source-map-js "^1.2.1"
data-urls@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde"
integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==
cssstyle@^6.0.1:
version "6.2.0"
resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-6.2.0.tgz#c41b59955c19c7a1223352d67ca462750204ad0f"
integrity sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==
dependencies:
whatwg-mimetype "^4.0.0"
whatwg-url "^14.0.0"
"@asamuzakjp/css-color" "^5.0.1"
"@csstools/css-syntax-patches-for-csstree" "^1.0.28"
css-tree "^3.1.0"
lru-cache "^11.2.6"
data-urls@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-7.0.0.tgz#6dce8b63226a1ecfdd907ce18a8ccfb1eee506d3"
integrity sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==
dependencies:
whatwg-mimetype "^5.0.0"
whatwg-url "^16.0.0"
debug@4, debug@^4.3.4:
version "4.4.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies:
ms "^2.1.3"
decimal.js@^10.5.0:
version "10.5.0"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.5.0.tgz#0f371c7cf6c4898ce0afb09836db73cd82010f22"
integrity sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==
decimal.js@^10.6.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a"
integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==
diff@^8.0.2:
version "8.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.2.tgz#712156a6dd288e66ebb986864e190c2fc9eddfae"
integrity sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==
diff@^8.0.3:
version "8.0.3"
resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.3.tgz#c7da3d9e0e8c283bb548681f8d7174653720c2d5"
integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==
dir-glob@^3.0.1:
version "3.0.1"
@@ -265,17 +320,17 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
dompurify@*, dompurify@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
dompurify@*, dompurify@^3.3.1:
version "3.3.3"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.3.3.tgz#680cae8af3e61320ddf3666a3bc843f7b291b2b6"
integrity sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
entities@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51"
integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==
version "6.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694"
integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==
esbuild-android-64@0.14.47:
version "0.14.47"
@@ -420,9 +475,9 @@ fast-glob@^3.2.9:
micromatch "^4.0.8"
fastq@^1.6.0:
version "1.19.1"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5"
integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==
version "1.20.1"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675"
integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==
dependencies:
reusify "^1.0.4"
@@ -457,12 +512,12 @@ graphemer@^1.4.0:
resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
html-encoding-sniffer@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448"
integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==
html-encoding-sniffer@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz#f8d9390b3b348b50d4f61c16dd2ef5c05980a882"
integrity sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==
dependencies:
whatwg-encoding "^3.1.1"
"@exodus/bytes" "^1.6.0"
http-proxy-agent@^7.0.2:
version "7.0.2"
@@ -480,13 +535,6 @@ https-proxy-agent@^7.0.6:
agent-base "^7.1.2"
debug "4"
iconv-lite@0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
ignore@^5.2.0, ignore@^5.3.1:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
@@ -515,43 +563,49 @@ is-potential-custom-element-name@^1.0.1:
integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
isomorphic-dompurify@^2.25.0:
version "2.25.0"
resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-2.25.0.tgz#063e3ea7399bc1146783a9527be6c10baa25dc15"
integrity sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==
version "2.36.0"
resolved "https://registry.yarnpkg.com/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz#91e0e554cc3130cc4b7bfb77264f58e51ebd561e"
integrity sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==
dependencies:
dompurify "^3.2.6"
jsdom "^26.1.0"
dompurify "^3.3.1"
jsdom "^28.0.0"
jsdom@^26.1.0:
version "26.1.0"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-26.1.0.tgz#ab5f1c1cafc04bd878725490974ea5e8bf0c72b3"
integrity sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==
jsdom@^28.0.0:
version "28.1.0"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-28.1.0.tgz#ac4203e58fd24d7b0f34359ab00d6d9caebd4b62"
integrity sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==
dependencies:
cssstyle "^4.2.1"
data-urls "^5.0.0"
decimal.js "^10.5.0"
html-encoding-sniffer "^4.0.0"
"@acemir/cssom" "^0.9.31"
"@asamuzakjp/dom-selector" "^6.8.1"
"@bramus/specificity" "^2.4.2"
"@exodus/bytes" "^1.11.0"
cssstyle "^6.0.1"
data-urls "^7.0.0"
decimal.js "^10.6.0"
html-encoding-sniffer "^6.0.0"
http-proxy-agent "^7.0.2"
https-proxy-agent "^7.0.6"
is-potential-custom-element-name "^1.0.1"
nwsapi "^2.2.16"
parse5 "^7.2.1"
rrweb-cssom "^0.8.0"
parse5 "^8.0.0"
saxes "^6.0.0"
symbol-tree "^3.2.4"
tough-cookie "^5.1.1"
tough-cookie "^6.0.0"
undici "^7.21.0"
w3c-xmlserializer "^5.0.0"
webidl-conversions "^7.0.0"
whatwg-encoding "^3.1.1"
whatwg-mimetype "^4.0.0"
whatwg-url "^14.1.1"
ws "^8.18.0"
webidl-conversions "^8.0.1"
whatwg-mimetype "^5.0.0"
whatwg-url "^16.0.0"
xml-name-validator "^5.0.0"
lru-cache@^10.4.3:
version "10.4.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lru-cache@^11.2.6:
version "11.2.7"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.7.tgz#9127402617f34cd6767b96daee98c28e74458d35"
integrity sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==
mdn-data@2.27.1:
version "2.27.1"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.27.1.tgz#e37b9c50880b75366c4d40ac63d9bbcacdb61f0e"
integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==
merge2@^1.3.0, merge2@^1.4.1:
version "1.4.1"
@@ -567,11 +621,11 @@ micromatch@^4.0.8:
picomatch "^2.3.1"
minimatch@^9.0.4:
version "9.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
version "9.0.9"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
dependencies:
brace-expansion "^2.0.1"
brace-expansion "^2.0.2"
moment@2.29.4:
version "2.29.4"
@@ -588,23 +642,18 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
nwsapi@^2.2.16:
version "2.2.20"
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.20.tgz#22e53253c61e7b0e7e93cef42c891154bcca11ef"
integrity sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==
obsidian@^1.6.6:
version "1.8.7"
resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.8.7.tgz#601e9ea1724289effa4c9bb3b4e20d327263634f"
integrity sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==
version "1.12.3"
resolved "https://registry.yarnpkg.com/obsidian/-/obsidian-1.12.3.tgz#5307fe4c36d6b3d554fd0d4e4732f756a7e1d1cd"
integrity sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==
dependencies:
"@types/codemirror" "5.60.8"
moment "2.29.4"
parse5@^7.2.1:
version "7.3.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05"
integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==
parse5@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-8.0.0.tgz#aceb267f6b15f9b6e6ba9e35bfdd481fc2167b12"
integrity sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==
dependencies:
entities "^6.0.0"
@@ -613,10 +662,10 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
picomatch@>=2.3.2, picomatch@^2.3.1:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
punycode@^2.3.1:
version "2.3.1"
@@ -628,16 +677,16 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
reusify@^1.0.4:
version "1.1.0"
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
rrweb-cssom@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz#3021d1b4352fbf3b614aaeed0bc0d5739abe0bc2"
integrity sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -645,11 +694,6 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
"safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
saxes@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
@@ -658,31 +702,36 @@ saxes@^6.0.0:
xmlchars "^2.2.0"
semver@^7.6.0:
version "7.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
tldts-core@^6.1.86:
version "6.1.86"
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.86.tgz#a93e6ed9d505cb54c542ce43feb14c73913265d8"
integrity sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==
tldts-core@^7.0.26:
version "7.0.26"
resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-7.0.26.tgz#070f14bc7a4deabf115c6501bc5c0bae4da74d17"
integrity sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==
tldts@^6.1.32:
version "6.1.86"
resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.86.tgz#087e0555b31b9725ee48ca7e77edc56115cd82f7"
integrity sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==
tldts@^7.0.5:
version "7.0.26"
resolved "https://registry.yarnpkg.com/tldts/-/tldts-7.0.26.tgz#bf2472ed84e55faaaff5c2424c03a6bab69b92c5"
integrity sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==
dependencies:
tldts-core "^6.1.86"
tldts-core "^7.0.26"
to-regex-range@^5.0.1:
version "5.0.1"
@@ -691,17 +740,17 @@ to-regex-range@^5.0.1:
dependencies:
is-number "^7.0.0"
tough-cookie@^5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.1.2.tgz#66d774b4a1d9e12dc75089725af3ac75ec31bed7"
integrity sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==
tough-cookie@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76"
integrity sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==
dependencies:
tldts "^6.1.32"
tldts "^7.0.5"
tr46@^5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.1.1.tgz#96ae867cddb8fdb64a49cc3059a8d428bcf238ca"
integrity sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==
tr46@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-6.0.0.tgz#f5a1ae546a0adb32a277a2278d0d17fa2f9093e6"
integrity sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==
dependencies:
punycode "^2.3.1"
@@ -720,6 +769,11 @@ typescript@4.7.4:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
undici@^7.21.0:
version "7.24.4"
resolved "https://registry.yarnpkg.com/undici/-/undici-7.24.4.tgz#873bce680d7c6354c941399fd4e8ea4563de4ea7"
integrity sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==
w3c-xmlserializer@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
@@ -727,35 +781,24 @@ w3c-xmlserializer@^5.0.0:
dependencies:
xml-name-validator "^5.0.0"
webidl-conversions@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
webidl-conversions@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686"
integrity sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==
whatwg-encoding@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5"
integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==
whatwg-mimetype@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz#d8232895dbd527ceaee74efd4162008fb8a8cf48"
integrity sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==
whatwg-url@^16.0.0:
version "16.0.1"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-16.0.1.tgz#047f7f4bd36ef76b7198c172d1b1cebc66f764dd"
integrity sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==
dependencies:
iconv-lite "0.6.3"
whatwg-mimetype@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a"
integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==
whatwg-url@^14.0.0, whatwg-url@^14.1.1:
version "14.2.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.2.0.tgz#4ee02d5d725155dae004f6ae95c73e7ef5d95663"
integrity sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==
dependencies:
tr46 "^5.1.0"
webidl-conversions "^7.0.0"
ws@^8.18.0:
version "8.18.2"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a"
integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==
"@exodus/bytes" "^1.11.0"
tr46 "^6.0.0"
webidl-conversions "^8.0.1"
xml-name-validator@^5.0.0:
version "5.0.0"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
yarn run lint-staged
yarn test
bun run lint-staged
bun run test

View File

@@ -5,19 +5,19 @@ This is a [Next.js](https://nextjs.org/) project.
First, install the dependencies:
```bash
yarn install
bun install
```
In case you run into any dependency linking issues, you can try running:
```bash
yarn add next
bun add next
```
### Run the development server:
```bash
yarn dev
bun dev
```
Make sure the `rewrites` in `next.config.mjs` are set up correctly for your environment. The rewrites are used to proxy requests to the API server.
@@ -44,27 +44,30 @@ You can start editing the page by modifying any of the `.tsx` pages. The page au
We've setup a utility command for building and serving the built files. This is useful for testing the production build locally.
1. Exporting code
To build the files once and serve them, run:
To build the files once and serve them, run:
```bash
yarn export
bun export
```
If you're using Windows:
```bash
yarn windowsexport
```
```bash
bun windowsexport
```
2. Continuously building code
To keep building the files and serving them, run:
```bash
yarn watch
bun watch
```
If you're using Windows:
```bash
yarn windowswatch
bun windowswatch
```
Now you should be able to load your custom pages from the Khoj app at http://localhost:42110/. To server any of the built files, you should update the routes in the `web_client.py` like so, where `new_file` is the new page you've added in this repo:

View File

@@ -283,9 +283,9 @@ export default function Agents() {
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
<h2 className="text-lg">Agents</h2>
)}
@@ -344,14 +344,16 @@ export default function Agents() {
/>
<span className="font-bold">How it works</span> Use any of these
specialized personas to tune your conversation to your needs.
{
!isSubscribed && (
<span>
{" "}
<Link href="/settings" className="font-bold">Upgrade your plan</Link> to leverage custom models. You will fallback to the default model when chatting.
</span>
)
}
{!isSubscribed && (
<span>
{" "}
<Link href="/settings" className="font-bold">
Upgrade your plan
</Link>{" "}
to leverage custom models. You will fallback to the
default model when chatting.
</span>
)}
</AlertDescription>
</Alert>
<div className="pt-6 md:pt-8">

View File

@@ -46,6 +46,7 @@ import { LocationData, useIPLocationData, useIsMobileWidth } from "../common/uti
import styles from "./automations.module.css";
import ShareLink from "../components/shareLink/shareLink";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import {
CalendarCheck,
@@ -1059,9 +1060,9 @@ export default function Automations() {
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
<h2 className="text-lg">Automations</h2>
)}

View File

@@ -124,3 +124,50 @@ div.chatTitleWrapper {
grid-template-columns: 1fr;
}
}
/* Print-specific styles for chat layout */
@media print {
/* Chat container adjustments */
div.main {
height: auto !important;
max-height: none !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
div.chatBox,
div.chatBoxBody,
div.chatLayout {
height: auto !important;
max-height: none !important;
width: 100% !important;
display: block !important;
margin: 0 !important;
padding: 0 !important;
overflow: visible !important;
position: static !important;
}
div.chatBodyFull,
div.chatBody {
display: block !important;
width: 100% !important;
height: auto !important;
max-height: none !important;
grid-template-columns: none !important;
overflow: visible !important;
position: static !important;
}
div.inputBox {
display: none !important;
}
/* Make chat content use full width in print */
.chatHistory {
width: 100% !important;
max-width: none !important;
}
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "../globals.css";
import { Toaster } from "@/components/ui/toaster";
export const metadata: Metadata = {
title: "Khoj AI - Chat",
@@ -39,6 +40,7 @@ export default function ChildLayout({
return (
<>
{children}
<Toaster />
<script
dangerouslySetInnerHTML={{
__html: `window.EXCALIDRAW_ASSET_PATH = 'https://assets.khoj.dev/@excalidraw/excalidraw/dist/';`,

View File

@@ -1,10 +1,12 @@
"use client";
import styles from "./chat.module.css";
import React, { Suspense, useEffect, useRef, useState } from "react";
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
import useWebSocket from "react-use-websocket";
import ChatHistory from "../components/chatHistory/chatHistory";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import Loading from "../components/loading/loading";
import { generateNewTitle, processMessageChunk } from "../common/chatFunctions";
@@ -27,11 +29,13 @@ import { useAuthenticatedData } from "../common/auth";
import { AgentData } from "@/app/components/agentCard/agentCard";
import { ChatSessionActionMenu } from "../components/allConversations/allConversations";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { DeprecationBanner } from "@/app/components/deprecationBanner";
import { AppSidebar } from "../components/appSidebar/appSidebar";
import { Separator } from "@/components/ui/separator";
import { KhojLogoType } from "../components/logo/khojLogo";
import { Button } from "@/components/ui/button";
import { Joystick } from "@phosphor-icons/react";
import { useToast } from "@/components/ui/use-toast";
import { ChatSidebar } from "../components/chatSidebar/chatSidebar";
interface ChatBodyDataProps {
@@ -45,7 +49,7 @@ interface ChatBodyDataProps {
isMobileWidth?: boolean;
isLoggedIn: boolean;
setImages: (images: string[]) => void;
setTriggeredAbort: (triggeredAbort: boolean) => void;
setTriggeredAbort: (triggeredAbort: boolean, newMessage?: string) => void;
isChatSideBarOpen: boolean;
setIsChatSideBarOpen: (open: boolean) => void;
isActive?: boolean;
@@ -162,7 +166,7 @@ function ChatBodyData(props: ChatBodyDataProps) {
/>
</div>
<div
className={`${styles.inputBox} p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-2xl md:rounded-xl h-fit ${chatHistoryCustomClassName} mr-auto ml-auto mt-auto`}
className={`${styles.inputBox} print-hidden p-1 md:px-2 shadow-md bg-background align-middle items-center justify-center dark:bg-neutral-700 dark:border-0 dark:shadow-sm rounded-2xl md:rounded-xl h-fit ${chatHistoryCustomClassName} mr-auto ml-auto mt-auto`}
>
<ChatInputArea
agentColor={agentMetadata?.color}
@@ -180,13 +184,15 @@ function ChatBodyData(props: ChatBodyDataProps) {
/>
</div>
</div>
<ChatSidebar
conversationId={conversationId}
isActive={props.isActive}
isOpen={props.isChatSideBarOpen}
onOpenChange={props.setIsChatSideBarOpen}
isMobileWidth={props.isMobileWidth}
/>
<div className="print-hidden">
<ChatSidebar
conversationId={conversationId}
isActive={props.isActive}
isOpen={props.isChatSideBarOpen}
onOpenChange={props.setIsChatSideBarOpen}
isMobileWidth={props.isMobileWidth}
/>
</div>
</div>
);
}
@@ -203,10 +209,10 @@ export default function Chat() {
const [uploadedFiles, setUploadedFiles] = useState<AttachedFileText[] | undefined>(undefined);
const [images, setImages] = useState<string[]>([]);
const [abortMessageStreamController, setAbortMessageStreamController] =
useState<AbortController | null>(null);
const [triggeredAbort, setTriggeredAbort] = useState(false);
const [shouldSendWithInterrupt, setShouldSendWithInterrupt] = useState(false);
const [interruptMessage, setInterruptMessage] = useState<string>("");
const bufferRef = useRef("");
const idleTimerRef = useRef<NodeJS.Timeout | null>(null);
const { locationData, locationDataError, locationDataLoading } = useIPLocationData() || {
locationData: {
@@ -220,6 +226,189 @@ export default function Chat() {
} = useAuthenticatedData();
const isMobileWidth = useIsMobileWidth();
const [isChatSideBarOpen, setIsChatSideBarOpen] = useState(false);
const [socketUrl, setSocketUrl] = useState<string | null>(null);
// track whether we've already shown a toast for the current disconnect cycle to avoid duplicates
const disconnectToastShownRef = useRef(false);
// Track whether the websocket is closing due to an intentional action (page refresh/navigation or idle timeout)
const intentionalCloseRef = useRef(false);
const disconnectFromServer = useCallback(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
// Mark as intentional so onClose does not show transient network error banner
intentionalCloseRef.current = true;
setSocketUrl(null);
console.log("WebSocket disconnected due to inactivity.");
}, []);
const resetIdleTimer = useCallback(() => {
const idleTimeout = 10 * 60 * 1000; // 10 minutes
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
idleTimerRef.current = setTimeout(disconnectFromServer, idleTimeout);
}, [disconnectFromServer]);
const { toast } = useToast();
const { sendMessage, lastMessage } = useWebSocket(socketUrl, {
share: true,
shouldReconnect: (closeEvent) => true,
reconnectAttempts: 10,
// reconnect using exponential backoff with jitter
reconnectInterval: (attemptNumber) => {
const baseDelay = 1000 * Math.pow(2, attemptNumber);
const jitter = Math.random() * 1000; // Add jitter up to 1s
return Math.min(baseDelay + jitter, 20000); // Cap backoff at 20s
},
onOpen: () => {
console.log("WebSocket connection established.");
resetIdleTimer();
// Reset disconnect toast guard so future disconnects can notify again
disconnectToastShownRef.current = false;
// Reset intentional close flag after a successful open
intentionalCloseRef.current = false;
},
onClose: (event) => {
console.log("WebSocket connection closed.");
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
// Suppress notice if:
// - Intentional close (page refresh/navigation or idle management)
// - Normal closure (1000) or Going Away (1001 - typical on page reload)
// - No query to process
if (
!intentionalCloseRef.current &&
event?.code !== 1000 &&
event?.code !== 1001 &&
queryToProcess
) {
if (!disconnectToastShownRef.current) {
toast({
title: "Network issue",
description:
"Connection lost. Please check your network and try again when ready.",
variant: "destructive",
duration: 6000,
});
disconnectToastShownRef.current = true;
}
}
// Mark any in-progress streamed message as completed so UI updates (stop spinner, show send icon)
setMessages((prev) => {
if (!prev || prev.length === 0) return prev;
const newMessages = [...prev];
const last = newMessages[newMessages.length - 1];
if (last && !last.completed) {
last.completed = true;
}
return newMessages;
});
// Reset processing state so ChatInputArea send button reappears
setProcessQuerySignal(false);
setQueryToProcess("");
},
onError: (event) => {
console.error("WebSocket error", event);
// Perform same cleanup as onClose to avoid stuck UI
setMessages((prev) => {
if (!prev || prev.length === 0) return prev;
const newMessages = [...prev];
const last = newMessages[newMessages.length - 1];
if (last && !last.completed) {
last.completed = true;
}
return newMessages;
});
setProcessQuerySignal(false);
setQueryToProcess("");
if (!intentionalCloseRef.current && !disconnectToastShownRef.current) {
toast({
title: "Network error",
description:
"Connection lost. Please check your network and try again when ready.",
variant: "destructive",
duration: 5000,
});
disconnectToastShownRef.current = true;
}
},
});
// Handle page unload / refresh: mark intentional so we don't show a toast
useEffect(() => {
const handleBeforeUnload = () => {
intentionalCloseRef.current = true;
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
useEffect(() => {
if (lastMessage !== null) {
resetIdleTimer();
// Check if this is a control message (JSON) rather than a streaming event
try {
const controlMessage = JSON.parse(lastMessage.data);
if (controlMessage.type === "interrupt_acknowledged") {
console.log("Interrupt acknowledged by server");
setProcessQuerySignal(false);
return;
} else if (controlMessage.type === "interrupt_message_acknowledged") {
console.log("Interrupt message acknowledged by server");
setProcessQuerySignal(false);
return;
} else if (controlMessage.error) {
console.error("WebSocket error:", controlMessage.error);
setProcessQuerySignal(false);
return;
}
} catch {
// Not a JSON control message, process as streaming event
}
const eventDelimiter = "␃🔚␗";
bufferRef.current += lastMessage.data;
let newEventIndex;
while ((newEventIndex = bufferRef.current.indexOf(eventDelimiter)) !== -1) {
const eventChunk = bufferRef.current.slice(0, newEventIndex);
bufferRef.current = bufferRef.current.slice(newEventIndex + eventDelimiter.length);
if (eventChunk) {
setMessages((prevMessages) => {
const newMessages = [...prevMessages];
const currentMessage = newMessages[newMessages.length - 1];
if (!currentMessage || currentMessage.completed) {
return prevMessages;
}
const { context, onlineContext, codeContext } = processMessageChunk(
eventChunk,
currentMessage,
currentMessage.context || [],
currentMessage.onlineContext || {},
currentMessage.codeContext || {},
);
// Update the current message with the new reference data
currentMessage.context = context;
currentMessage.onlineContext = onlineContext;
currentMessage.codeContext = codeContext;
if (currentMessage.completed) {
setQueryToProcess("");
setProcessQuerySignal(false);
setImages([]);
if (conversationId) generateNewTitle(conversationId, setTitle);
}
return newMessages;
});
}
}
}
}, [lastMessage, setMessages]);
useEffect(() => {
fetch("/api/chat/options")
@@ -239,14 +428,37 @@ export default function Chat() {
welcomeConsole();
}, []);
const handleTriggeredAbort = (value: boolean, newMessage?: string) => {
if (value) {
setInterruptMessage(newMessage || "");
}
setTriggeredAbort(value);
};
useEffect(() => {
if (triggeredAbort) {
abortMessageStreamController?.abort();
handleAbortedMessage();
setShouldSendWithInterrupt(true);
setTriggeredAbort(false);
sendMessage(
JSON.stringify({
type: "interrupt",
query: interruptMessage,
}),
);
console.log("Sent interrupt message via WebSocket:", interruptMessage);
// Mark the last message as completed
setMessages((prevMessages) => {
const newMessages = [...prevMessages];
const currentMessage = newMessages[newMessages.length - 1];
if (currentMessage) currentMessage.completed = true;
return newMessages;
});
// Set the interrupt message as the new query being processed
setQueryToProcess(interruptMessage);
setTriggeredAbort(false); // Always set to false after processing
setInterruptMessage("");
}
}, [triggeredAbort]);
}, [triggeredAbort, sendMessage]);
useEffect(() => {
if (queryToProcess) {
@@ -264,7 +476,6 @@ export default function Chat() {
};
setMessages((prevMessages) => [...prevMessages, newStreamMessage]);
setProcessQuerySignal(true);
setAbortMessageStreamController(new AbortController());
}
}, [queryToProcess]);
@@ -278,70 +489,19 @@ export default function Chat() {
}
}, [processQuerySignal, locationDataLoading]);
async function readChatStream(response: Response) {
if (!response.ok) throw new Error(response.statusText);
if (!response.body) throw new Error("Response body is null");
useEffect(() => {
if (!conversationId) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
const eventDelimiter = "␃🔚␗";
let buffer = "";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?client=web`;
setSocketUrl(wsUrl);
// Track context used for chat response
let context: Context[] = [];
let onlineContext: OnlineContext = {};
let codeContext: CodeContext = {};
while (true) {
const { done, value } = await reader.read();
if (done) {
setQueryToProcess("");
setProcessQuerySignal(false);
setImages([]);
if (conversationId) generateNewTitle(conversationId, setTitle);
break;
return () => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
}
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
let newEventIndex;
while ((newEventIndex = buffer.indexOf(eventDelimiter)) !== -1) {
const event = buffer.slice(0, newEventIndex);
buffer = buffer.slice(newEventIndex + eventDelimiter.length);
if (event) {
const currentMessage = messages.find((message) => !message.completed);
if (!currentMessage) {
console.error("No current message found");
return;
}
// Track context used for chat response. References are rendered at the end of the chat
({ context, onlineContext, codeContext } = processMessageChunk(
event,
currentMessage,
context,
onlineContext,
codeContext,
));
setMessages([...messages]);
}
}
}
}
function handleAbortedMessage() {
const currentMessage = messages.find((message) => !message.completed);
if (!currentMessage) return;
currentMessage.completed = true;
setMessages([...messages]);
setProcessQuerySignal(false);
}
};
}, [conversationId]);
async function chat() {
localStorage.removeItem("message");
@@ -349,12 +509,19 @@ export default function Chat() {
setProcessQuerySignal(false);
return;
}
const chatAPI = "/api/chat?client=web";
// Re-establish WebSocket connection if disconnected
resetIdleTimer();
if (!socketUrl) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?client=web`;
setSocketUrl(wsUrl);
}
const chatAPIBody = {
q: queryToProcess,
conversation_id: conversationId,
stream: true,
interrupt: shouldSendWithInterrupt,
...(locationData && {
city: locationData.city,
region: locationData.region,
@@ -366,58 +533,7 @@ export default function Chat() {
...(uploadedFiles && { files: uploadedFiles }),
};
// Reset the flag after using it
setShouldSendWithInterrupt(false);
const response = await fetch(chatAPI, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(chatAPIBody),
signal: abortMessageStreamController?.signal,
});
try {
await readChatStream(response);
} catch (err) {
let apiError;
try {
apiError = await response.json();
} catch (err) {
// Error reading API error response
apiError = {
streamError: "Error reading API error response stream. Expected JSON response.",
};
}
console.error(apiError);
// Retrieve latest message being processed
const currentMessage = messages.find((message) => !message.completed);
if (!currentMessage) return;
// Render error message as current message
const errorMessage = (err as Error).message;
const errorName = (err as Error).name;
if (errorMessage.includes("Error in input stream"))
currentMessage.rawResponse = `Woops! The connection broke while I was writing my thoughts down. Maybe try again in a bit or dislike this message if the issue persists?`;
else if (apiError.streamError) {
currentMessage.rawResponse = `Umm, not sure what just happened but I lost my train of thought. Could you try again or ask my developers to look into this if the issue persists? They can be contacted at the Khoj Github, Discord or team@khoj.dev.`;
} else if (response.status === 429) {
"detail" in apiError
? (currentMessage.rawResponse = `${apiError.detail}`)
: (currentMessage.rawResponse = `I'm a bit overwhelmed at the moment. Could you try again in a bit or dislike this message if the issue persists?`);
} else if (errorName === "AbortError") {
currentMessage.rawResponse = `I've stopped processing this message. If you'd like to continue, please send the message again.`;
} else {
currentMessage.rawResponse = `Umm, not sure what just happened. I see this error message: ${errorMessage}. Could you try again or dislike this message if the issue persists?`;
}
// Complete message streaming teardown properly
currentMessage.completed = true;
setMessages([...messages]);
setQueryToProcess("");
setProcessQuerySignal(false);
}
sendMessage(JSON.stringify(chatAPIBody));
}
const handleConversationIdChange = (newConversationId: string) => {
@@ -458,9 +574,12 @@ export default function Chat() {
return (
<SidebarProvider>
<AppSidebar conversationId={conversationId || ""} />
<div className="print-hidden">
<AppSidebar conversationId={conversationId || ""} />
</div>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<DeprecationBanner />
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4 print-hidden">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{conversationId && (
@@ -468,9 +587,9 @@ export default function Chat() {
className={`${styles.chatTitleWrapper} text-nowrap text-ellipsis overflow-hidden max-w-screen-md grid items-top font-bold mx-2 md:mr-8 col-auto h-fit`}
>
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
title && (
<>
@@ -493,7 +612,7 @@ export default function Chat() {
<Button
variant="ghost"
size="icon"
className="h-12 w-12 data-[state=open]:bg-accent"
className="h-12 w-12 data-[state=open]:bg-accent print-hidden"
onClick={() => setIsChatSideBarOpen(!isChatSideBarOpen)}
>
<Joystick className="w-6 h-6" />
@@ -518,7 +637,7 @@ export default function Chat() {
isMobileWidth={isMobileWidth}
onConversationIdChange={handleConversationIdChange}
setImages={setImages}
setTriggeredAbort={setTriggeredAbort}
setTriggeredAbort={handleTriggeredAbort}
isChatSideBarOpen={isChatSideBarOpen}
setIsChatSideBarOpen={setIsChatSideBarOpen}
isActive={authenticatedData?.is_active}

View File

@@ -63,6 +63,8 @@ export interface UserConfig {
enabled_content_source: SyncedContent;
has_documents: boolean;
notion_token: string | null;
enable_memory: boolean;
server_memory_mode: "disabled" | "enabled_default_off" | "enabled_default_on";
// user model settings
search_model_options: ModelOptions[];
selected_search_model_config: number;
@@ -90,11 +92,9 @@ export interface UserConfig {
export function useUserConfig(detailed: boolean = false) {
const url = `/api/settings?detailed=${detailed}`;
const {
data,
error,
isLoading,
} = useSWR<UserConfig>(url, fetcher, { revalidateOnFocus: false });
const { data, error, isLoading } = useSWR<UserConfig>(url, fetcher, {
revalidateOnFocus: false,
});
if (error || !data || data?.detail === "Forbidden") {
return { data: null, error, isLoading };

View File

@@ -1,12 +1,12 @@
"use client"
"use client";
import * as React from "react"
import * as React from "react";
import { useState, useEffect } from "react";
import { PopoverProps } from "@radix-ui/react-popover"
import { PopoverProps } from "@radix-ui/react-popover";
import { Check, CaretUpDown } from "@phosphor-icons/react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
import { useIsMobileWidth, useMutationObserver } from "@/app/common/utils";
import { Button } from "@/components/ui/button";
import {
@@ -17,11 +17,7 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ModelOptions, useUserConfig } from "./auth";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
@@ -35,7 +31,7 @@ interface ModelSelectorProps extends PopoverProps {
}
export function ModelSelector({ ...props }: ModelSelectorProps) {
const [open, setOpen] = React.useState(false)
const [open, setOpen] = React.useState(false);
const [peekedModel, setPeekedModel] = useState<ModelOptions | undefined>(undefined);
const [selectedModel, setSelectedModel] = useState<ModelOptions | undefined>(undefined);
const { data: userConfig, error, isLoading: isLoadingUserConfig } = useUserConfig(true);
@@ -48,14 +44,18 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
if (userConfig) {
setModels(userConfig.chat_model_options);
if (!props.initialModel) {
const selectedChatModelOption = userConfig.chat_model_options.find(model => model.id === userConfig.selected_chat_model_config);
const selectedChatModelOption = userConfig.chat_model_options.find(
(model) => model.id === userConfig.selected_chat_model_config,
);
if (!selectedChatModelOption && userConfig.chat_model_options.length > 0) {
setSelectedModel(userConfig.chat_model_options[0]);
} else {
setSelectedModel(selectedChatModelOption);
}
} else {
const model = userConfig.chat_model_options.find(model => model.name === props.initialModel);
const model = userConfig.chat_model_options.find(
(model) => model.name === props.initialModel,
);
setSelectedModel(model);
}
}
@@ -68,15 +68,11 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
}, [selectedModel, userConfig, props.onSelect]);
if (isLoadingUserConfig) {
return (
<Skeleton className="w-full h-10" />
);
return <Skeleton className="w-full h-10" />;
}
if (error) {
return (
<div className="text-sm text-error">{error.message}</div>
);
return <div className="text-sm text-error">{error.message}</div>;
}
return (
@@ -92,30 +88,85 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
disabled={props.disabled ?? false}
>
<p className="truncate">
{selectedModel ? selectedModel.name.substring(0, 20) : "Select a model..."}
{selectedModel
? selectedModel.name?.substring(0, 20)
: "Select a model..."}
</p>
<CaretUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[250px] p-0">
{
isMobileWidth ?
{isMobileWidth ? (
<div>
<Command loop>
<CommandList className="h-[var(--cmdk-list-height)]">
<CommandInput placeholder="Search Models..." />
<CommandEmpty>No Models found.</CommandEmpty>
<CommandGroup key={"models"} heading={"Models"}>
{models &&
models.length > 0 &&
models.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModel?.id === model.id}
onPeek={(model) => setPeekedModel(model)}
onSelect={() => {
setSelectedModel(model);
setOpen(false);
}}
isActive={props.isActive}
/>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
) : (
<HoverCard>
<HoverCardContent
side="left"
align="start"
forceMount
className="min-h-[280px]"
>
<div className="grid gap-2">
<h4 className="font-medium leading-none">
{peekedModel?.name}
</h4>
<div className="text-sm text-muted-foreground">
{peekedModel?.description}
</div>
{peekedModel?.strengths ? (
<div className="mt-4 grid gap-2">
<h5 className="text-sm font-medium leading-none">
Strengths
</h5>
<p className="text-sm text-muted-foreground">
{peekedModel.strengths}
</p>
</div>
) : null}
</div>
</HoverCardContent>
<div>
<HoverCardTrigger />
<Command loop>
<CommandList className="h-[var(--cmdk-list-height)]">
<CommandInput placeholder="Search Models..." />
<CommandEmpty>No Models found.</CommandEmpty>
<CommandGroup key={"models"} heading={"Models"}>
{models && models.length > 0 && models
.map((model) => (
{models &&
models.length > 0 &&
models.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModel?.id === model.id}
onPeek={(model) => setPeekedModel(model)}
onSelect={() => {
setSelectedModel(model)
setOpen(false)
setSelectedModel(model);
setOpen(false);
}}
isActive={props.isActive}
/>
@@ -124,74 +175,24 @@ export function ModelSelector({ ...props }: ModelSelectorProps) {
</CommandList>
</Command>
</div>
:
<HoverCard>
<HoverCardContent
side="left"
align="start"
forceMount
className="min-h-[280px]"
>
<div className="grid gap-2">
<h4 className="font-medium leading-none">{peekedModel?.name}</h4>
<div className="text-sm text-muted-foreground">
{peekedModel?.description}
</div>
{peekedModel?.strengths ? (
<div className="mt-4 grid gap-2">
<h5 className="text-sm font-medium leading-none">
Strengths
</h5>
<p className="text-sm text-muted-foreground">
{peekedModel.strengths}
</p>
</div>
) : null}
</div>
</HoverCardContent>
<div>
<HoverCardTrigger />
<Command loop>
<CommandList className="h-[var(--cmdk-list-height)]">
<CommandInput placeholder="Search Models..." />
<CommandEmpty>No Models found.</CommandEmpty>
<CommandGroup key={"models"} heading={"Models"}>
{models && models.length > 0 && models
.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModel?.id === model.id}
onPeek={(model) => setPeekedModel(model)}
onSelect={() => {
setSelectedModel(model)
setOpen(false)
}}
isActive={props.isActive}
/>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
</HoverCard>
}
</HoverCard>
)}
</PopoverContent>
</Popover>
</div>
)
);
}
interface ModelItemProps {
model: ModelOptions,
isSelected: boolean,
onSelect: () => void,
onPeek: (model: ModelOptions) => void
isActive?: boolean
model: ModelOptions;
isSelected: boolean;
onSelect: () => void;
onPeek: (model: ModelOptions) => void;
isActive?: boolean;
}
function ModelItem({ model, isSelected, onSelect, onPeek, isActive }: ModelItemProps) {
const ref = React.useRef<HTMLDivElement>(null)
const ref = React.useRef<HTMLDivElement>(null);
useMutationObserver(ref, (mutations) => {
mutations.forEach((mutation) => {
@@ -200,10 +201,10 @@ function ModelItem({ model, isSelected, onSelect, onPeek, isActive }: ModelItemP
mutation.attributeName === "aria-selected" &&
ref.current?.getAttribute("aria-selected") === "true"
) {
onPeek(model)
onPeek(model);
}
})
})
});
});
return (
<CommandItem
@@ -213,10 +214,9 @@ function ModelItem({ model, isSelected, onSelect, onPeek, isActive }: ModelItemP
className="data-[selected=true]:bg-muted data-[selected=true]:text-secondary-foreground"
disabled={!isActive && model.tier !== "free"}
>
{model.name} {model.tier === "standard" && <span className="text-green-500 ml-2">(Futurist)</span>}
<Check
className={cn("ml-auto", isSelected ? "opacity-100" : "opacity-0")}
/>
{model.name}{" "}
{model.tier === "standard" && <span className="text-green-500 ml-2">(Futurist)</span>}
<Check className={cn("ml-auto", isSelected ? "opacity-100" : "opacity-0")} />
</CommandItem>
)
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import useSWR from "swr";
import * as React from "react"
import * as React from "react";
export interface LocationData {
city?: string;
@@ -78,16 +78,16 @@ export const useMutationObserver = (
characterData: true,
childList: true,
subtree: true,
}
},
) => {
React.useEffect(() => {
if (ref.current) {
const observer = new MutationObserver(callback)
observer.observe(ref.current, options)
return () => observer.disconnect()
const observer = new MutationObserver(callback);
observer.observe(ref.current, options);
return () => observer.disconnect();
}
}, [ref, callback, options])
}
}, [ref, callback, options]);
};
export function useIsDarkMode() {
const [darkMode, setDarkMode] = useState(false);

View File

@@ -1,6 +1,7 @@
import styles from "./agentCard.module.css";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { UserProfile, ModelOptions, UserConfig } from "@/app/common/auth";
import { Button } from "@/components/ui/button";
@@ -705,7 +706,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
</AlertDialogHeader>
<AlertDialogDescription>
You need to be a Futurist subscriber to create more agents.{" "}
<a href="/settings">Upgrade now</a>.
<Link href="/settings">Upgrade now</Link>.
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel
@@ -767,7 +768,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
<FormDescription>
{!props.isSubscribed ? (
<p className="text-secondary-foreground">
Upgrade to the <a href="/settings">Futurist plan</a> to
Upgrade to the <Link href="/settings">Futurist plan</Link> to
access all models.
</p>
) : (
@@ -971,7 +972,7 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
<FormLabel>Knowledge Base</FormLabel>
<FormDescription>
Which information should be part of its digital brain?{" "}
<a href="/settings">Manage data</a>.
<Link href="/settings">Manage data</Link>.
</FormDescription>
<Collapsible>
<CollapsibleTrigger className="flex items-center justify-between text-sm gap-2 bg-muted p-2 rounded-lg">
@@ -1061,12 +1062,27 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
className="h-6 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
const filteredFiles = allFileOptions.filter(file =>
file.toLowerCase().includes(fileSearchValue.toLowerCase())
const filteredFiles =
allFileOptions.filter((file) =>
file
.toLowerCase()
.includes(
fileSearchValue.toLowerCase(),
),
);
const currentFiles =
props.form.getValues("files") ||
[];
const newFiles = [
...new Set([
...currentFiles,
...filteredFiles,
]),
];
props.form.setValue(
"files",
newFiles,
);
const currentFiles = props.form.getValues("files") || [];
const newFiles = [...new Set([...currentFiles, ...filteredFiles])];
props.form.setValue("files", newFiles);
}}
>
Select All
@@ -1078,12 +1094,28 @@ export function AgentModificationForm(props: AgentModificationFormProps) {
className="h-6 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
const filteredFiles = allFileOptions.filter(file =>
file.toLowerCase().includes(fileSearchValue.toLowerCase())
const filteredFiles =
allFileOptions.filter((file) =>
file
.toLowerCase()
.includes(
fileSearchValue.toLowerCase(),
),
);
const currentFiles =
props.form.getValues("files") ||
[];
const newFiles =
currentFiles.filter(
(file) =>
!filteredFiles.includes(
file,
),
);
props.form.setValue(
"files",
newFiles,
);
const currentFiles = props.form.getValues("files") || [];
const newFiles = currentFiles.filter(file => !filteredFiles.includes(file));
props.form.setValue("files", newFiles);
}}
>
Deselect All

View File

@@ -127,7 +127,7 @@ function renameConversation(conversationId: string, newTitle: string) {
},
})
.then((response) => response.json())
.then((data) => { })
.then((data) => {})
.catch((err) => {
console.error(err);
return;
@@ -171,7 +171,7 @@ function deleteConversation(conversationId: string) {
mutate("/api/chat/sessions");
}
})
.then((data) => { })
.then((data) => {})
.catch((err) => {
console.error(err);
return;
@@ -245,9 +245,7 @@ export function FilesMenu(props: FilesMenuProps) {
Context
<p>
<span className="text-muted-foreground text-xs">
{
error ? "Failed to load files" : "Failed to load selected files"
}
{error ? "Failed to load files" : "Failed to load selected files"}
</span>
</p>
</h4>
@@ -257,7 +255,7 @@ export function FilesMenu(props: FilesMenuProps) {
</Button>
</div>
</div>
)
);
}
if (!files) return <InlineLoading />;
@@ -443,10 +441,7 @@ function SessionsAndFiles(props: SessionsAndFilesProps) {
<div>
{props.sideBarOpen && (
<ScrollArea>
<ScrollAreaScrollbar
orientation="vertical"
className="h-full w-2.5"
/>
<ScrollAreaScrollbar orientation="vertical" className="h-full w-2.5" />
<div className="p-0 m-0">
{props.subsetOrganizedData != null &&
Object.keys(props.subsetOrganizedData)
@@ -471,7 +466,9 @@ function SessionsAndFiles(props: SessionsAndFilesProps) {
agent_name={chatHistory.agent_name}
agent_color={chatHistory.agent_color}
agent_icon={chatHistory.agent_icon}
agent_is_hidden={chatHistory.agent_is_hidden}
agent_is_hidden={
chatHistory.agent_is_hidden
}
/>
),
)}
@@ -709,7 +706,7 @@ function ChatSession(props: ChatHistory) {
className="flex items-center gap-2 no-underline"
>
<p
className={`${styles.session} ${props.compressed ? styles.compressed : 'max-w-[15rem] md:max-w-[22rem]'}`}
className={`${styles.session} ${props.compressed ? styles.compressed : "max-w-[15rem] md:max-w-[22rem]"}`}
>
{title}
</p>
@@ -949,7 +946,7 @@ export default function AllConversations(props: SidePanelProps) {
const currentDate = new Date();
chatSessions.forEach((chatSessionMetadata) => {
chatSessions?.forEach((chatSessionMetadata) => {
const chatDate = new Date(chatSessionMetadata.updated);
const diffTime = Math.abs(currentDate.getTime() - chatDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));

View File

@@ -26,6 +26,8 @@ import { useIsDarkMode, useIsMobileWidth } from "@/app/common/utils";
import { UserPlusIcon } from "lucide-react";
import { useAuthenticatedData, UserProfile } from "@/app/common/auth";
import LoginPrompt from "../loginPrompt/loginPrompt";
import { usePathname } from "next/navigation";
import Link from "next/link";
async function openChat(userData: UserProfile | null | undefined) {
const unauthenticatedRedirectUrl = `/login?redirect=${encodeURIComponent(window.location.pathname)}`;
@@ -48,13 +50,12 @@ async function openChat(userData: UserProfile | null | undefined) {
}
}
// Menu items.
const items = [
{
title: "Home",
url: "/",
icon: HouseSimple
icon: HouseSimple,
},
{
title: "Agents",
@@ -89,17 +90,21 @@ interface AppSidebarProps {
export function AppSidebar(props: AppSidebarProps) {
const isMobileWidth = useIsMobileWidth();
const { data, isLoading, error } = useAuthenticatedData();
const pathname = usePathname();
const { state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar } =
useSidebar();
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
// Check if we're on a shared chat page
const isSharedChatPage = pathname?.startsWith("/share/chat");
useEffect(() => {
if (!isLoading && !data) {
if (!isLoading && !data && !isSharedChatPage) {
setShowLoginPrompt(true);
}
}, [isLoading, data]);
}, [isLoading, data, isSharedChatPage]);
return (
<Sidebar collapsible={"icon"} variant="sidebar" className="md:py-2">
@@ -108,15 +113,15 @@ export function AppSidebar(props: AppSidebarProps) {
<SidebarMenuItem className="p-0 m-0">
{open ? (
<SidebarMenuButton>
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
</SidebarMenuButton>
) : (
<SidebarMenuButton asChild>
<a className="flex items-center gap-2 no-underline" href="/">
<Link className="flex items-center gap-2 no-underline" href="/">
<KhojLogo className="w-14 h-auto" />
</a>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>

View File

@@ -16,3 +16,45 @@ div.trainOfThought {
padding: 8px 16px;
margin: 12px;
}
/* If there is an inline element holding extremely long content, ensure it wraps */
div.trainOfThought pre,
div.trainOfThought code,
div.trainOfThought p,
div.trainOfThought span {
overflow-wrap: anywhere;
}
/* Print-specific styles for chat history */
@media print {
div.chatHistory {
height: auto !important;
max-height: none !important;
overflow: visible !important;
display: block !important;
position: static !important;
flex-direction: column !important;
}
div.chatHistory > * {
height: auto !important;
max-height: none !important;
overflow: visible !important;
position: static !important;
width: 100% !important;
max-width: none !important;
}
/* Show agent indicators clearly in print */
div.agentIndicator {
margin-bottom: 0.5rem !important;
}
/* Train of thought styling for print */
div.trainOfThought {
border-left: 2px solid #ccc !important;
margin: 0.5rem 0 !important;
padding: 0.5rem 1rem !important;
font-size: 0.9em !important;
}
}

View File

@@ -24,6 +24,7 @@ import { AgentData } from "@/app/components/agentCard/agentCard";
import React from "react";
import { useIsMobileWidth } from "@/app/common/utils";
import { Button } from "@/components/ui/button";
import { KhojLogo } from "../logo/khojLogo";
interface ChatResponse {
status: string;
@@ -51,7 +52,7 @@ interface TrainOfThoughtFrame {
}
interface TrainOfThoughtGroup {
type: 'video' | 'text';
type: "video" | "text";
frames?: TrainOfThoughtFrame[];
textEntries?: TrainOfThoughtObject[];
}
@@ -64,7 +65,9 @@ interface TrainOfThoughtComponentProps {
completed?: boolean;
}
function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): TrainOfThoughtGroup[] {
function extractTrainOfThoughtGroups(
trainOfThought?: TrainOfThoughtObject[],
): TrainOfThoughtGroup[] {
if (!trainOfThought) return [];
const groups: TrainOfThoughtGroup[] = [];
@@ -93,8 +96,8 @@ function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): T
// If we have accumulated text entries, add them as a text group
if (currentTextEntries.length > 0) {
groups.push({
type: 'text',
textEntries: [...currentTextEntries]
type: "text",
textEntries: [...currentTextEntries],
});
currentTextEntries = [];
}
@@ -115,8 +118,8 @@ function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): T
// If we have accumulated video frames, add them as a video group
if (currentVideoFrames.length > 0) {
groups.push({
type: 'video',
frames: [...currentVideoFrames]
type: "video",
frames: [...currentVideoFrames],
});
currentVideoFrames = [];
}
@@ -129,14 +132,14 @@ function extractTrainOfThoughtGroups(trainOfThought?: TrainOfThoughtObject[]): T
// Add any remaining frames/entries
if (currentVideoFrames.length > 0) {
groups.push({
type: 'video',
frames: currentVideoFrames
type: "video",
frames: currentVideoFrames,
});
}
if (currentTextEntries.length > 0) {
groups.push({
type: 'text',
textEntries: currentTextEntries
type: "text",
textEntries: currentTextEntries,
});
}
@@ -176,10 +179,10 @@ function TrainOfThoughtComponent(props: TrainOfThoughtComponentProps) {
// Convert string array to TrainOfThoughtObject array if needed
let trainOfThoughtObjects: TrainOfThoughtObject[];
if (typeof props.trainOfThought[0] === 'string') {
if (typeof props.trainOfThought[0] === "string") {
trainOfThoughtObjects = (props.trainOfThought as string[]).map((data, index) => ({
type: 'text',
data: data
type: "text",
data: data,
}));
} else {
trainOfThoughtObjects = props.trainOfThought as TrainOfThoughtObject[];
@@ -220,28 +223,37 @@ function TrainOfThoughtComponent(props: TrainOfThoughtComponentProps) {
<motion.div initial="closed" animate="open" exit="closed" variants={variants}>
{trainOfThoughtGroups.map((group, groupIndex) => (
<div key={`train-group-${groupIndex}`}>
{group.type === 'video' && group.frames && group.frames.length > 0 && (
<TrainOfThoughtVideoPlayer
frames={group.frames}
autoPlay={false}
playbackSpeed={1500}
/>
)}
{group.type === 'text' && group.textEntries && group.textEntries.map((entry, entryIndex) => {
const lastIndex = trainOfThoughtGroups.length - 1;
const isLastGroup = groupIndex === lastIndex;
const isLastEntry = entryIndex === group.textEntries!.length - 1;
const isPrimaryEntry = isLastGroup && isLastEntry && props.lastMessage && !props.completed;
return (
<TrainOfThought
key={`train-text-${groupIndex}-${entryIndex}-${entry.data.length}`}
message={entry.data}
primary={isPrimaryEntry}
agentColor={props.agentColor}
{group.type === "video" &&
group.frames &&
group.frames.length > 0 && (
<TrainOfThoughtVideoPlayer
frames={group.frames}
autoPlay={false}
playbackSpeed={1500}
/>
);
})}
)}
{group.type === "text" &&
group.textEntries &&
group.textEntries.map((entry, entryIndex) => {
const lastIndex = trainOfThoughtGroups.length - 1;
const isLastGroup = groupIndex === lastIndex;
const isLastEntry =
entryIndex === group.textEntries!.length - 1;
const isPrimaryEntry =
isLastGroup &&
isLastEntry &&
props.lastMessage &&
!props.completed;
return (
<TrainOfThought
key={`train-text-${groupIndex}-${entryIndex}-${entry.data.length}`}
message={entry.data}
primary={isPrimaryEntry}
agentColor={props.agentColor}
/>
);
})}
</div>
))}
</motion.div>
@@ -299,7 +311,8 @@ export default function ChatHistory(props: ChatHistoryProps) {
// ResizeObserver to handle content height changes (e.g., images loading)
useEffect(() => {
const contentWrapper = scrollableContentWrapperRef.current;
const scrollViewport = scrollAreaRef.current?.querySelector<HTMLElement>(scrollAreaSelector);
const scrollViewport =
scrollAreaRef.current?.querySelector<HTMLElement>(scrollAreaSelector);
if (!contentWrapper || !scrollViewport) return;
@@ -307,14 +320,18 @@ export default function ChatHistory(props: ChatHistoryProps) {
// Check current scroll position to decide if auto-scroll is warranted
const { scrollTop, scrollHeight, clientHeight } = scrollViewport;
const bottomThreshold = 50;
const currentlyNearBottom = (scrollHeight - (scrollTop + clientHeight)) <= bottomThreshold;
const currentlyNearBottom =
scrollHeight - (scrollTop + clientHeight) <= bottomThreshold;
if (currentlyNearBottom) {
// Only auto-scroll if there are incoming messages being processed
if (props.incomingMessages && props.incomingMessages.length > 0) {
const lastMessage = props.incomingMessages[props.incomingMessages.length - 1];
// If the last message is not completed, or it just completed (indicated by incompleteIncomingMessageIndex still being set)
if (!lastMessage.completed || (lastMessage.completed && incompleteIncomingMessageIndex !== null)) {
if (
!lastMessage.completed ||
(lastMessage.completed && incompleteIncomingMessageIndex !== null)
) {
scrollToBottom(true); // Use instant scroll
}
}
@@ -462,7 +479,12 @@ export default function ChatHistory(props: ChatHistoryProps) {
});
});
// Optimistically set, the scroll listener will verify
if (instant || scrollAreaEl && (scrollAreaEl.scrollHeight - (scrollAreaEl.scrollTop + scrollAreaEl.clientHeight)) < 5) {
if (
instant ||
(scrollAreaEl &&
scrollAreaEl.scrollHeight - (scrollAreaEl.scrollTop + scrollAreaEl.clientHeight) <
5)
) {
setIsNearBottom(true);
}
};
@@ -536,6 +558,24 @@ export default function ChatHistory(props: ChatHistoryProps) {
ref={scrollAreaRef}
>
<div ref={scrollableContentWrapperRef}>
{/* Print-only header with conversation info */}
<div className="print-only-header">
<div className="print-header-content">
<div className="print-header-left">
<KhojLogo className="print-logo" />
</div>
<div className="print-header-right">
<h1>{data?.slug || "Conversation with Khoj"}</h1>
<div className="conversation-meta">
<p>
<strong>Agent:</strong> {constructAgentName()}
</p>
</div>
</div>
</div>
<hr />
</div>
<div className={`${styles.chatHistory} ${props.customClassName}`}>
<div ref={sentinelRef} style={{ height: "1px" }}>
{fetchingData && <InlineLoading className="opacity-50" />}
@@ -607,16 +647,19 @@ export default function ChatHistory(props: ChatHistoryProps) {
conversationId={props.conversationId}
turnId={messageTurnId}
/>
{message.trainOfThought && message.trainOfThought.length > 0 && (
<TrainOfThoughtComponent
trainOfThought={message.trainOfThought}
lastMessage={index === incompleteIncomingMessageIndex}
agentColor={data?.agent?.color || "orange"}
key={`${index}trainOfThought-${message.trainOfThought.length}-${message.trainOfThought.map(t => t.length).join('-')}`}
keyId={`${index}trainOfThought`}
completed={message.completed}
/>
)}
{message.trainOfThought &&
message.trainOfThought.length > 0 && (
<TrainOfThoughtComponent
trainOfThought={message.trainOfThought}
lastMessage={
index === incompleteIncomingMessageIndex
}
agentColor={data?.agent?.color || "orange"}
key={`${index}trainOfThought-${message.trainOfThought.length}-${message.trainOfThought.map((t) => t.length).join("-")}`}
keyId={`${index}trainOfThought`}
completed={message.completed}
/>
)}
<ChatMessage
key={`${index}incoming`}
isMobileWidth={isMobileWidth}

View File

@@ -82,7 +82,7 @@ interface ChatInputProps {
isLoggedIn: boolean;
agentColor?: string;
isResearchModeEnabled?: boolean;
setTriggeredAbort: (value: boolean) => void;
setTriggeredAbort: (value: boolean, newMessage?: string) => void;
prefillMessage?: string;
focus?: ChatInputFocus;
}
@@ -189,9 +189,11 @@ export const ChatInputArea = forwardRef<HTMLTextAreaElement, ChatInputProps>((pr
return;
}
// If currently processing, trigger abort first
// If currently processing, handle interrupt first
if (props.sendDisabled) {
props.setTriggeredAbort(true);
props.setTriggeredAbort(true, message.trim());
setMessage(""); // Clear the input
return; // Don't continue with regular message sending
}
if (imageUploaded) {

View File

@@ -0,0 +1,61 @@
"use client";
import React from "react";
interface FileContentSnippetProps {
content: string;
targetLine?: number;
maxLines?: number; // Used when no target line; defaults to 20
}
export default function FileContentSnippet({
content,
targetLine,
maxLines = 20,
}: FileContentSnippetProps) {
const lines = (content || "").split("\n");
if (targetLine && targetLine > 0 && targetLine <= lines.length) {
const startLine = Math.max(1, targetLine - 2);
const endLine = Math.min(lines.length, targetLine + 5);
const contextLines = lines.slice(startLine - 1, endLine);
return (
<pre className="whitespace-pre-wrap text-xs">
{contextLines.map((line, idx) => {
const lineNum = startLine + idx;
const isTarget = lineNum === targetLine;
return (
<div
key={lineNum}
className={isTarget ? "bg-green-100 dark:bg-green-900" : ""}
>
<span className="text-gray-400 select-none mr-2">
{lineNum.toString().padStart(3, " ")}:
</span>
{line}
</div>
);
})}
</pre>
);
}
const previewLines = lines.slice(0, maxLines);
return (
<pre className="whitespace-pre-wrap text-xs">
{previewLines.map((line, index) => (
<div key={index}>
<span className="text-gray-400 select-none mr-2">
{(index + 1).toString().padStart(3, " ")}:
</span>
{line}
</div>
))}
{lines.length > maxLines && (
<div className="text-gray-500 italic">
... and {lines.length - maxLines} more lines
</div>
)}
</pre>
);
}

View File

@@ -23,6 +23,57 @@ div.chatMessageWrapper a span {
display: revert !important;
}
/* File link styling */
.chatMessageWrapper a.file-link {
color: hsl(var(--primary));
text-decoration: underline;
cursor: pointer;
transition: color 0.2s ease;
}
.chatMessageWrapper a.file-link:hover {
color: hsl(var(--primary-foreground));
background-color: hsl(var(--primary));
text-decoration: none;
padding: 2px 4px;
border-radius: 4px;
}
/* Table styling */
.chatMessageWrapper table {
border-collapse: collapse;
border: 1px solid hsl(var(--border));
margin: 1rem 0;
width: 100%;
}
.chatMessageWrapper table th,
.chatMessageWrapper table td {
border: 1px solid hsl(var(--border));
padding: 8px 12px;
text-align: left;
min-width: 120px;
}
.chatMessageWrapper table th {
background-color: hsl(var(--muted));
font-weight: 600;
}
/* Alternating row colors for better readability */
.chatMessageWrapper table tbody tr:nth-child(even) {
background-color: hsl(var(--muted) / 0.3);
}
.chatMessageWrapper table tbody tr:nth-child(odd) {
background-color: transparent;
}
/* Hover effect for table rows */
.chatMessageWrapper table tbody tr:hover {
background-color: hsl(var(--muted) / 0.5);
}
div.khojfullHistory {
padding-left: 4px;
}
@@ -76,7 +127,9 @@ div.imageWrapper img {
}
div.khoj div.imageWrapper img {
height: 512px;
max-height: 512px;
height: auto;
object-fit: contain;
}
div.khoj div.imageWrapper {
@@ -197,4 +250,141 @@ div.trainOfThoughtElement ul {
width: 100%;
height: auto;
}
.chatMessageWrapper table th,
.chatMessageWrapper table td {
min-width: 40px;
}
}
/* Print-specific styles for chat messages */
@media print {
div.chatMessageContainer {
background: transparent !important;
border: 1px solid #ccc !important;
border-radius: 8px !important;
margin: 0.5rem 0 !important;
padding: 0.5rem !important;
width: 100% !important;
max-width: none !important;
}
div.chatMessageContainer.you {
margin: 1rem 0 0 0 !important;
background: transparent !important;
border: none !important;
}
div.chatMessageContainer.khoj {
margin: 0 !important;
}
div.chatMessageWrapper {
padding-left: 0.5rem !important;
padding-bottom: 0.5rem !important;
width: 100% !important;
max-width: none !important;
}
div.you div.chatMessageWrapper {
padding: 0 !important;
}
div.you {
background-color: transparent !important;
color: #000 !important;
font-size: 16pt !important;
font-weight: 500 !important;
padding: 0.8rem 0 0.1rem 0 !important;
align-self: flex-start !important;
text-align: left !important;
}
div.khoj {
background-color: transparent !important;
color: #000 !important;
width: 100% !important;
max-width: none !important;
align-self: stretch !important;
}
div.youfullHistory,
div.khojfullHistory {
width: 100% !important;
max-width: none !important;
}
div.author {
color: #666 !important;
font-size: 0.7rem !important;
}
/* Add timestamp as divider between title and body */
div.you.chatMessageContainer::after {
content: "🕐 " attr(data-created) !important;
display: block !important;
color: #888 !important;
font-size: 9pt !important;
font-weight: normal !important;
margin: 0.2rem 0 0.6rem 0 !important;
padding-bottom: 0.4rem !important;
}
/* Hide interactive elements */
div.chatFooter,
div.chatButtons,
button.codeCopyButton,
button.copyButton,
button.retryButton,
div.feedbackButtons {
display: none !important;
}
/* Image styling for print */
div.imagesContainer {
display: block !important;
overflow: visible !important;
margin-bottom: 0.5rem !important;
}
div.imageWrapper {
margin-right: 0 !important;
margin-bottom: 0.5rem !important;
}
div.imageWrapper img,
div.khoj div.imageWrapper img {
width: auto !important;
height: auto !important;
max-width: 100% !important;
max-height: 4in !important;
object-fit: contain !important;
page-break-inside: avoid;
}
/* Train of thought styling for print */
div.trainOfThought {
border-left: 2px solid #ccc !important;
margin: 0.5rem 0 !important;
padding: 0.5rem !important;
font-size: 0.9em !important;
color: #666 !important;
}
div.trainOfThought strong {
color: #000 !important;
}
div.trainOfThought.primary {
border-left-color: #000 !important;
}
div.trainOfThought.primary strong {
color: #000 !important;
}
div.trainOfThoughtElement {
display: block !important;
margin-bottom: 0.5rem !important;
}
}

View File

@@ -6,11 +6,19 @@ import markdownIt from "markdown-it";
import mditHljs from "markdown-it-highlightjs";
import React, { useEffect, useRef, useState, forwardRef } from "react";
import { createRoot } from "react-dom/client";
import { createPortal } from "react-dom";
import "katex/dist/katex.min.css";
import { TeaserReferencesSection, constructAllReferences } from "../referencePanel/referencePanel";
import {
TeaserReferencesSection,
constructAllReferences,
} from "@/app/components/referencePanel/referencePanel";
import { renderCodeGenImageInline } from "@/app/common/chatFunctions";
import { fileLinksPlugin } from "@/app/components/chatMessage/fileLinksPlugin";
import { imageValidationPlugin } from "@/app/components/chatMessage/imageValidationPlugin";
import FileContentSnippet from "@/app/components/chatMessage/FileContentSnippet";
import { useFileContent } from "@/app/components/chatMessage/useFileContent";
import {
ThumbsUp,
@@ -41,7 +49,6 @@ import { convertColorToTextClass } from "@/app/common/colorUtils";
import { AgentData } from "@/app/components/agentCard/agentCard";
import renderMathInElement from "katex/contrib/auto-render";
import "katex/dist/katex.min.css";
import ExcalidrawComponent from "../excalidraw/excalidraw";
import { AttachedFileText } from "../chatInputArea/chatInputArea";
import {
@@ -68,6 +75,9 @@ md.use(mditHljs, {
code: true,
});
md.use(fileLinksPlugin);
md.use(imageValidationPlugin);
export interface Context {
compiled: string;
file: string;
@@ -304,11 +314,15 @@ function chooseIconFromHeader(header: string, iconColor: string) {
return <Toolbox className={`${classNames}`} />;
}
if (compareHeader.includes("notes")) {
if (
compareHeader.includes("notes") ||
compareHeader.includes("documents") ||
compareHeader.includes("files")
) {
return <Folder className={`${classNames}`} />;
}
if (compareHeader.includes("read")) {
if (compareHeader.includes("browsing")) {
return <Book className={`${classNames}`} />;
}
@@ -385,6 +399,43 @@ export function TrainOfThought(props: TrainOfThoughtProps) {
);
}
// Clean mermaid chart by removing/fixing invalid syntax patterns
function cleanMermaidChart(chart: string): string {
return chart
.split("\n")
.filter((line) => !line.trim().match(/^title\s*\[.*\]\s*$/i)) // Remove invalid title[...] lines
.map((line) => {
// Fix parentheses inside square bracket node labels: [Text (with parens)]
// Mermaid interprets () as special syntax, so we need to quote the content
// Replace [Label (text)] with ["Label (text)"]
return line.replace(/\[([^\]]*\([^\]]*\)[^\]]*)\]/g, '["$1"]');
})
.join("\n");
}
// Extract mermaid code blocks from markdown content
function extractMermaidBlocks(content: string): { cleanedContent: string; mermaidBlocks: string[] } {
const mermaidBlocks: string[] = [];
// Match ```mermaid ... ``` code blocks
// Allow optional whitespace before/after delimiters and handle various line endings
const mermaidRegex = /```\s*mermaid\s*\r?\n([\s\S]*?)```/gi;
const cleanedContent = content.replace(mermaidRegex, (match, mermaidCode) => {
const trimmedCode = mermaidCode.trim();
if (trimmedCode) {
// Clean the mermaid chart before adding
const cleanedChart = cleanMermaidChart(trimmedCode);
if (cleanedChart.trim()) {
mermaidBlocks.push(cleanedChart);
}
}
// Replace with empty string to remove from markdown
return "";
});
return { cleanedContent, mermaidBlocks };
}
const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) => {
const [copySuccess, setCopySuccess] = useState<boolean>(false);
const [isHovering, setIsHovering] = useState<boolean>(false);
@@ -394,6 +445,24 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
const [interrupted, setInterrupted] = useState<boolean>(false);
const [excalidrawData, setExcalidrawData] = useState<string>("");
const [mermaidjsData, setMermaidjsData] = useState<string>("");
const [inlineMermaidBlocks, setInlineMermaidBlocks] = useState<string[]>([]);
// State for file content preview on file link click, hover
const [previewOpen, setPreviewOpen] = useState<boolean>(false);
const [previewFilePath, setPreviewFilePath] = useState<string>("");
const [previewLineNumber, setPreviewLineNumber] = useState<number | undefined>(undefined);
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [previewContent, setPreviewContent] = useState<string>("");
const [hoverOpen, setHoverOpen] = useState<boolean>(false);
const [hoverFilePath, setHoverFilePath] = useState<string>("");
const [hoverLineNumber, setHoverLineNumber] = useState<number | undefined>(undefined);
const [hoverLoading, setHoverLoading] = useState<boolean>(false);
const [hoverError, setHoverError] = useState<string | null>(null);
const [hoverContent, setHoverContent] = useState<string>("");
const [hoverPos, setHoverPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const hoverCloseTimeoutRef = useRef<number | null>(null);
const interruptedRef = useRef<boolean>(false);
const messageRef = useRef<HTMLDivElement>(null);
@@ -441,12 +510,10 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
setMermaidjsData(props.chatMessage.mermaidjsDiagram);
}
// Replace LaTeX delimiters with placeholders
message = message
.replace(/\\\(/g, "LEFTPAREN")
.replace(/\\\)/g, "RIGHTPAREN")
.replace(/\\\[/g, "LEFTBRACKET")
.replace(/\\\]/g, "RIGHTBRACKET");
// Extract mermaid blocks from the message content
const { cleanedContent, mermaidBlocks } = extractMermaidBlocks(message);
message = cleanedContent;
setInlineMermaidBlocks(mermaidBlocks);
// Replace file links with base64 data
message = renderCodeGenImageInline(message, props.chatMessage.codeContext);
@@ -465,7 +532,7 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
});
}
// Handle user attached images rendering
// Handle rendering user attached or khoj generated images
let messageForClipboard = message;
let messageToRender = message;
if (props.chatMessage.images && props.chatMessage.images.length > 0) {
@@ -477,12 +544,12 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
});
const imagesInMd = sanitizedImages
.map((sanitizedImage, index) => {
return `![uploaded image ${index + 1}](${sanitizedImage})`;
return `![rendered image ${index + 1}](${sanitizedImage})`;
})
.join("\n");
const imagesInHtml = sanitizedImages
.map((sanitizedImage, index) => {
return `<div class="${styles.imageWrapper}"><img src="${sanitizedImage}" alt="uploaded image ${index + 1}" /></div>`;
return `<div class="${styles.imageWrapper}"><img src="${sanitizedImage}" alt="rendered image ${index + 1}" /></div>`;
})
.join("");
const userImagesInHtml = `<div class="${styles.imagesContainer}">${imagesInHtml}</div>`;
@@ -493,10 +560,27 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
// Set the message text
setTextRendered(messageForClipboard);
// Replace LaTeX delimiters with placeholders
messageToRender = messageToRender
.replace(/\\\(/g, "LEFTPAREN")
.replace(/\\\)/g, "RIGHTPAREN")
.replace(/\\\[/g, "LEFTBRACKET")
.replace(/\\\]/g, "RIGHTBRACKET");
// Preprocess file:// links so markdown-it processes them
// We convert them to a custom scheme (filelink://) and handle in the plugin
messageToRender = messageToRender.replace(
/\[([^\]]+)\]\(file:\/\/([^)]+)\)/g,
(match, text, path) => {
// Use a special scheme that markdown-it will process
return `[${text}](filelink://${path})`;
},
);
// Render the markdown
let markdownRendered = md.render(messageToRender);
// Replace placeholders with LaTeX delimiters
// Revert placeholders with LaTeX delimiters
markdownRendered = markdownRendered
.replace(/LEFTPAREN/g, "\\(")
.replace(/RIGHTPAREN/g, "\\)")
@@ -504,7 +588,12 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
.replace(/RIGHTBRACKET/g, "\\]");
// Sanitize and set the rendered markdown
setMarkdownRendered(DOMPurify.sanitize(markdownRendered));
// Configure DOMPurify to allow file link attributes
const cleanMarkdown = DOMPurify.sanitize(markdownRendered, {
ADD_ATTR: ["data-file-path", "data-line-number"],
});
setMarkdownRendered(cleanMarkdown);
}, [props.chatMessage.message, props.chatMessage.images, props.chatMessage.intent]);
useEffect(() => {
@@ -542,6 +631,90 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
}
});
// Event delegation on the message container for reliability
const container = messageRef.current;
const delegatedPointerDown = (ev: Event) => {
const e = ev as MouseEvent;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
if (!anchor) return;
e.preventDefault();
e.stopPropagation();
const path = anchor.getAttribute("data-file-path") || "";
const line = anchor.getAttribute("data-line-number") || undefined;
if (!path) return;
// Close hover popover if open
setHoverOpen(false);
setPreviewFilePath(path);
setPreviewLineNumber(line ? parseInt(line) : undefined);
setPreviewOpen(true);
};
let currentHoverAnchor: HTMLAnchorElement | null = null;
const delegatedMouseOver = (ev: Event) => {
const e = ev as MouseEvent;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
if (!anchor) return;
if (currentHoverAnchor === anchor) return;
currentHoverAnchor = anchor;
const rect = anchor.getBoundingClientRect();
const path = anchor.getAttribute("data-file-path") || "";
const line = anchor.getAttribute("data-line-number") || undefined;
if (!path) return;
setHoverPos({ x: Math.max(8, rect.left), y: rect.bottom + 6 });
setHoverFilePath(path);
setHoverLineNumber(line ? parseInt(line) : undefined);
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
hoverCloseTimeoutRef.current = null;
}
// Open immediately for reliability
setHoverOpen(true);
};
const delegatedMouseOut = (ev: Event) => {
const e = ev as MouseEvent;
const target = e.target as HTMLElement | null;
const related = e.relatedTarget as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
const stillInsideAnchor = !!(related && anchor && anchor.contains(related));
// If moving between descendants of the same anchor, ignore
if (stillInsideAnchor) return;
// Schedule close; will be canceled if we move into the popover
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
}
hoverCloseTimeoutRef.current = window.setTimeout(() => {
setHoverOpen(false);
currentHoverAnchor = null;
hoverCloseTimeoutRef.current = null;
}, 200);
};
const delegatedKeyDown = (ev: Event) => {
const e = ev as KeyboardEvent;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.("a.file-link") as HTMLAnchorElement | null;
if (!anchor) return;
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
const path = anchor.getAttribute("data-file-path") || "";
const line = anchor.getAttribute("data-line-number") || undefined;
if (!path) return;
setHoverOpen(false);
setPreviewFilePath(path);
setPreviewLineNumber(line ? parseInt(line) : undefined);
setPreviewOpen(true);
};
container.addEventListener("pointerdown", delegatedPointerDown);
container.addEventListener("keydown", delegatedKeyDown);
container.addEventListener("mouseover", delegatedMouseOver);
container.addEventListener("mouseout", delegatedMouseOut);
renderMathInElement(messageRef.current, {
delimiters: [
{ left: "$$", right: "$$", display: true },
@@ -549,12 +722,70 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
{ left: "\\(", right: "\\)", display: false },
],
});
// Cleanup old listeners when content changes
return () => {
container.removeEventListener("pointerdown", delegatedPointerDown);
container.removeEventListener("keydown", delegatedKeyDown);
container.removeEventListener("mouseover", delegatedMouseOver);
container.removeEventListener("mouseout", delegatedMouseOut);
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
hoverCloseTimeoutRef.current = null;
}
};
}
}, [markdownRendered, isHovering, messageRef]);
}, [markdownRendered, messageRef]);
// Fetch file content for dialog and hover using shared hook
const {
content: previewContentHook,
loading: previewLoadingHook,
error: previewErrorHook,
} = useFileContent(previewFilePath, previewOpen);
const {
content: hoverContentHook,
loading: hoverLoadingHook,
error: hoverErrorHook,
} = useFileContent(hoverFilePath, hoverOpen);
useEffect(() => {
setPreviewContent(previewContentHook);
}, [previewContentHook]);
useEffect(() => {
setPreviewLoading(previewLoadingHook);
}, [previewLoadingHook]);
useEffect(() => {
setPreviewError(previewErrorHook);
}, [previewErrorHook]);
useEffect(() => {
setHoverContent(hoverContentHook);
}, [hoverContentHook]);
useEffect(() => {
setHoverLoading(hoverLoadingHook);
}, [hoverLoadingHook]);
useEffect(() => {
setHoverError(hoverErrorHook);
}, [hoverErrorHook]);
function formatDate(timestamp: string) {
// Format date in HH:MM, DD MMM YYYY format
let date = new Date(timestamp + "Z");
// Handle timestamps in "YYYY-MM-DD HH:MM:SS" format from backend
let date: Date;
if (timestamp.includes(" ") && !timestamp.includes("T")) {
// Convert "YYYY-MM-DD HH:MM:SS" to ISO format
date = new Date(timestamp.replace(" ", "T") + "Z");
} else if (!timestamp.endsWith("Z")) {
date = new Date(timestamp + "Z");
} else {
date = new Date(timestamp);
}
// Check if date is valid
if (isNaN(date.getTime())) {
return "Invalid Date";
}
let time_string = date
.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true })
.toUpperCase();
@@ -713,6 +944,7 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
className={constructClasses(props.chatMessage)}
onMouseLeave={(event) => setIsHovering(false)}
onMouseEnter={(event) => setIsHovering(true)}
data-created={formatDate(props.chatMessage.created)}
>
<div className={chatMessageWrapperClasses(props.chatMessage)}>
{props.chatMessage.queryFiles && props.chatMessage.queryFiles.length > 0 && (
@@ -760,8 +992,125 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
className={styles.chatMessage}
dangerouslySetInnerHTML={{ __html: markdownRendered }}
/>
{/* File preview hover dialog */}
{hoverOpen &&
typeof window !== "undefined" &&
createPortal(
<div
onMouseEnter={() => {
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
hoverCloseTimeoutRef.current = null;
}
setHoverOpen(true);
}}
onMouseDown={(e) => {
// If user clicks the hover preview, open the dialog for the same file
e.preventDefault();
e.stopPropagation();
setHoverOpen(false);
if (hoverFilePath) {
setPreviewFilePath(hoverFilePath);
setPreviewLineNumber(hoverLineNumber);
setPreviewOpen(true);
}
}}
onMouseLeave={() => {
if (hoverCloseTimeoutRef.current) {
window.clearTimeout(hoverCloseTimeoutRef.current);
}
hoverCloseTimeoutRef.current = window.setTimeout(() => {
setHoverOpen(false);
hoverCloseTimeoutRef.current = null;
}, 200);
}}
style={{
position: "fixed",
left: hoverPos.x,
top: hoverPos.y,
zIndex: 9999,
}}
className="w-96 max-h-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md"
>
<div className="space-y-2">
<div className="flex items-center text-sm font-medium">
<span className="truncate">
{hoverFilePath.split("/").pop() || hoverFilePath}
</span>
{hoverLineNumber && (
<span className="text-gray-500 ml-2">
- Line {hoverLineNumber}
</span>
)}
</div>
<ScrollArea className="max-h-60">
{hoverLoading && (
<div className="flex items-center justify-center p-4">
<InlineLoading />
</div>
)}
{!hoverLoading && hoverError && (
<div className="p-3 text-red-500 text-sm">
Error: {hoverError}
</div>
)}
{!hoverLoading && !hoverError && (
<div className="text-sm">
<FileContentSnippet
content={hoverContent}
targetLine={hoverLineNumber}
maxLines={8}
/>
</div>
)}
</ScrollArea>
</div>
</div>,
document.body,
)}
{/* File preview popup dialog */}
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
<div className="truncate min-w-0 break-words break-all text-wrap max-w-full whitespace-normal">
{previewFilePath.split("/").pop() || previewFilePath}
<span className="text-gray-500 ml-2">
{previewLineNumber ? `- Line ${previewLineNumber}` : ""}
</span>
</div>
</DialogTitle>
</DialogHeader>
<div className="text-left">
<ScrollArea className="h-80 w-full rounded-md">
{previewLoading && (
<div className="flex items-center justify-center p-4">
<InlineLoading />
</div>
)}
{!previewLoading && previewError && (
<div className="p-3 text-red-500 text-sm">
Error: {previewError}
</div>
)}
{!previewLoading && !previewError && (
<div className="text-sm">
<FileContentSnippet
content={previewContent}
targetLine={previewLineNumber}
maxLines={20}
/>
</div>
)}
</ScrollArea>
</div>
</DialogContent>
</Dialog>
{excalidrawData && <ExcalidrawComponent data={excalidrawData} />}
{mermaidjsData && <Mermaid chart={mermaidjsData} />}
{inlineMermaidBlocks.map((chart, index) => (
<Mermaid key={`inline-mermaid-${index}`} chart={chart} />
))}
</div>
<div className={styles.teaserReferencesContainer}>
<TeaserReferencesSection
@@ -816,38 +1165,44 @@ const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>((props, ref) =>
/>
</button>
)}
{props.chatMessage.by === "khoj" && props.onRetryMessage && props.isLastMessage && (
<button
title="Retry"
className={`${styles.retryButton}`}
onClick={() => {
const turnId = props.chatMessage.turnId || props.turnId;
const query = props.chatMessage.rawQuery || props.chatMessage.intent?.query;
console.log("Retry button clicked for turnId:", turnId);
console.log("ChatMessage data:", {
rawQuery: props.chatMessage.rawQuery,
intent: props.chatMessage.intent,
message: props.chatMessage.message
});
console.log("Extracted query:", query);
if (query) {
props.onRetryMessage?.(query, turnId);
} else {
console.error("No original query found for retry");
// Fallback: try to get from a previous user message or show an input dialog
const fallbackQuery = prompt("Enter the original query to retry:");
if (fallbackQuery) {
props.onRetryMessage?.(fallbackQuery, turnId);
{props.chatMessage.by === "khoj" &&
props.onRetryMessage &&
props.isLastMessage && (
<button
title="Retry"
className={`${styles.retryButton}`}
onClick={() => {
const turnId = props.chatMessage.turnId || props.turnId;
const query =
props.chatMessage.rawQuery ||
props.chatMessage.intent?.query;
console.log("Retry button clicked for turnId:", turnId);
console.log("ChatMessage data:", {
rawQuery: props.chatMessage.rawQuery,
intent: props.chatMessage.intent,
message: props.chatMessage.message,
});
console.log("Extracted query:", query);
if (query) {
props.onRetryMessage?.(query, turnId);
} else {
console.error("No original query found for retry");
// Fallback: try to get from a previous user message or show an input dialog
const fallbackQuery = prompt(
"Enter the original query to retry:",
);
if (fallbackQuery) {
props.onRetryMessage?.(fallbackQuery, turnId);
}
}
}
}}
>
<ArrowClockwise
alt="Retry Message"
className="hsl(var(--muted-foreground)) hover:text-blue-500"
/>
</button>
)}
}}
>
<ArrowClockwise
alt="Retry Message"
className="hsl(var(--muted-foreground)) hover:text-blue-500"
/>
</button>
)}
<button
title="Copy"
className={`${styles.copyButton}`}

View File

@@ -0,0 +1,52 @@
import MarkdownIt from "markdown-it";
// File link renderer plugin for markdown-it
// Handles links of the form [text](file:///path/to/file) or [text](file:///path/to/file#line=123)
export function fileLinksPlugin(md: MarkdownIt) {
// Store the original link_open renderer
const defaultLinkOpenRenderer =
md.renderer.rules.link_open ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
// Override the link_open renderer
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const hrefIndex = token.attrIndex("href");
if (hrefIndex >= 0) {
const href = token.attrs![hrefIndex][1];
// Check if this is a filelink:// link (our preprocessed file:// links)
if (href.startsWith("filelink://")) {
// Extract file path and line number from filelink://path format
const filePath = href.replace("filelink://", "");
const fileMatch = filePath.match(/^(.+?)(?:#line=(\d+))?$/);
if (fileMatch) {
const actualFilePath = fileMatch[1];
const lineNumber = fileMatch[2];
// Add custom attributes for file links
token.attrSet("data-file-path", actualFilePath);
if (lineNumber) {
token.attrSet("data-line-number", lineNumber);
}
// Append class if it exists; otherwise set it
const classIdx = token.attrIndex("class");
if (classIdx >= 0 && token.attrs) {
token.attrs[classIdx][1] = `${token.attrs[classIdx][1]} file-link`;
} else {
token.attrSet("class", "file-link");
}
token.attrSet("href", "#"); // Prevent default navigation
token.attrSet("role", "button");
token.attrSet("tabindex", "0");
}
}
}
return defaultLinkOpenRenderer(tokens, idx, options, env, self);
};
}

View File

@@ -0,0 +1,70 @@
import MarkdownIt from "markdown-it";
/**
* Checks if a URL is a valid, loadable image URL.
* Returns true for URLs that browsers can actually fetch:
* - data: URLs (base64 encoded)
* - blob: URLs (object URLs)
* - http:// and https:// URLs
*/
function isValidImageUrl(url: string): boolean {
if (!url || typeof url !== "string") {
return false;
}
const trimmedUrl = url.trim();
// Allow data URLs (base64 encoded images)
if (trimmedUrl.startsWith("data:")) {
return true;
}
// Allow blob URLs
if (trimmedUrl.startsWith("blob:")) {
return true;
}
// Allow HTTP/HTTPS URLs
if (trimmedUrl.startsWith("http://") || trimmedUrl.startsWith("https://")) {
return true;
}
// Reject everything else (file://, relative paths, absolute paths, etc.)
return false;
}
/**
* Image validation plugin for markdown-it
* Filters out images with invalid/non-loadable URLs at render time.
* This prevents broken images from ever being added to the DOM.
*/
export function imageValidationPlugin(md: MarkdownIt) {
// Store the original image renderer
const defaultImageRenderer =
md.renderer.rules.image ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
// Override the image renderer
md.renderer.rules.image = function (tokens, idx, options, env, self) {
const token = tokens[idx];
const srcIndex = token.attrIndex("src");
if (srcIndex >= 0 && token.attrs) {
const src = token.attrs[srcIndex][1];
// If the URL is not valid, don't render the image at all
if (!isValidImageUrl(src)) {
// Return alt text as fallback, or empty string
const altText = token.content || "";
if (altText) {
return `<em style="color: #888; font-size: 0.9em;">[Image: ${md.utils.escapeHtml(altText)}]</em>`;
}
return "";
}
}
return defaultImageRenderer(tokens, idx, options, env, self);
};
}

View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
export interface UseFileContentResult {
content: string;
loading: boolean;
error: string | null;
}
// Fetch file content for a given path when `enabled` is true.
export function useFileContent(path: string | undefined, enabled: boolean): UseFileContentResult {
const [content, setContent] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function run() {
if (!enabled || !path) return;
setLoading(true);
setError(null);
setContent("");
try {
const resp = await fetch(`/api/content/file?file_name=${encodeURIComponent(path)}`);
if (!resp.ok) {
throw new Error(`Failed to fetch file content (${resp.status})`);
}
const data = await resp.json();
if (!cancelled) setContent(data.raw_text || "");
} catch (err) {
if (!cancelled)
setError(err instanceof Error ? err.message : "Failed to load file content");
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, [path, enabled]);
return { content, loading, error };
}

View File

@@ -1,10 +1,29 @@
"use client"
"use client";
import { ArrowsDownUp, CaretCircleDown, CheckCircle, Circle, CircleNotch, PersonSimpleTaiChi, Sparkle } from "@phosphor-icons/react";
import {
ArrowsDownUp,
CaretCircleDown,
CheckCircle,
Circle,
CircleNotch,
PersonSimpleTaiChi,
Sparkle,
} from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
import { ModelSelector } from "@/app/common/modelSelector";
import { FilesMenu } from "../allConversations/allConversations";
@@ -14,21 +33,39 @@ import { mutate } from "swr";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { AgentData } from "../agentCard/agentCard";
import { useEffect, useState } from "react";
import { getAvailableIcons, getIconForSlashCommand, getIconFromIconName } from "@/app/common/iconUtils";
import {
getAvailableIcons,
getIconForSlashCommand,
getIconFromIconName,
} from "@/app/common/iconUtils";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
import { TooltipContent } from "@radix-ui/react-tooltip";
import { useAuthenticatedData } from "@/app/common/auth";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { convertColorToTextClass, tailwindColors } from "@/app/common/colorUtils";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { motion } from "framer-motion";
interface ChatSideBarProps {
conversationId: string;
isOpen: boolean;
@@ -40,15 +77,10 @@ interface ChatSideBarProps {
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export function ChatSidebar({ ...props }: ChatSideBarProps) {
if (props.isMobileWidth) {
return (
<Sheet
open={props.isOpen}
onOpenChange={props.onOpenChange}>
<SheetContent
className="w-[300px] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
>
<Sheet open={props.isOpen} onOpenChange={props.onOpenChange}>
<SheetContent className="w-[300px] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden">
<ChatSidebarInternal {...props} />
</SheetContent>
</Sheet>
@@ -56,7 +88,7 @@ export function ChatSidebar({ ...props }: ChatSideBarProps) {
}
return (
<div className="relative">
<div className="relative h-full">
<ChatSidebarInternal {...props} />
</div>
);
@@ -110,14 +142,14 @@ function AgentCreationForm(props: IAgentCreationProps) {
fetch(createAgentUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
"Content-Type": "application/json",
},
body: JSON.stringify(data)
body: JSON.stringify(data),
})
.then((res) => res.json())
.then((data: AgentData | AgentError) => {
console.log("Success:", data);
if ('detail' in data) {
if ("detail" in data) {
setError(`Error creating agent: ${data.detail}`);
setIsCreating(false);
return;
@@ -142,162 +174,151 @@ function AgentCreationForm(props: IAgentCreationProps) {
}, [customAgentName, customAgentIcon, customAgentColor]);
return (
<Dialog>
<DialogTrigger asChild>
<Button
className="w-full"
variant="secondary"
>
<Button className="w-full" variant="secondary">
Create Agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
{
doneCreating && createdSlug ? (
<DialogTitle>
Created {customAgentName}
</DialogTitle>
) : (
<DialogTitle>
Create a New Agent
</DialogTitle>
)
}
{doneCreating && createdSlug ? (
<DialogTitle>Created {customAgentName}</DialogTitle>
) : (
<DialogTitle>Create a New Agent</DialogTitle>
)}
<DialogClose />
<DialogDescription>
If these settings have been helpful, create a dedicated agent you can re-use across conversations.
If these settings have been helpful, create a dedicated agent you can re-use
across conversations.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{
doneCreating && createdSlug ? (
<div className="flex flex-col items-center justify-center gap-4 py-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 260,
damping: 20
}}
>
<CheckCircle
className="w-16 h-16 text-green-500"
weight="fill"
/>
</motion.div>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-center text-lg font-medium text-accent-foreground"
>
Created successfully!
</motion.p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Link href={`/agents?agent=${createdSlug}`}>
<Button variant="secondary" className="mt-2">
Manage Agent
</Button>
</Link>
</motion.div>
{doneCreating && createdSlug ? (
<div className="flex flex-col items-center justify-center gap-4 py-8">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 260,
damping: 20,
}}
>
<CheckCircle className="w-16 h-16 text-green-500" weight="fill" />
</motion.div>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="text-center text-lg font-medium text-accent-foreground"
>
Created successfully!
</motion.p>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Link href={`/agents?agent=${createdSlug}`}>
<Button variant="secondary" className="mt-2">
Manage Agent
</Button>
</Link>
</motion.div>
</div>
) : (
<div className="flex flex-col gap-4">
<div>
<Label htmlFor="agent_name">Name</Label>
<Input
id="agent_name"
className="w-full p-2 border mt-4 border-slate-500 rounded-lg"
disabled={isCreating}
value={customAgentName}
onChange={(e) => setCustomAgentName(e.target.value)}
/>
</div>
) :
<div className="flex flex-col gap-4">
<div>
<Label htmlFor="agent_name">Name</Label>
<Input
id="agent_name"
className="w-full p-2 border mt-4 border-slate-500 rounded-lg"
disabled={isCreating}
value={customAgentName}
onChange={(e) => setCustomAgentName(e.target.value)}
/>
<div className="flex gap-4">
<div className="flex-1">
<Select
onValueChange={setCustomAgentColor}
defaultValue={customAgentColor}
>
<SelectTrigger
className="w-full dark:bg-muted"
disabled={isCreating}
>
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent className="items-center space-y-1 inline-flex flex-col">
{colorOptions.map((colorOption) => (
<SelectItem key={colorOption} value={colorOption}>
<div className="flex items-center space-x-2">
<Circle
className={`w-6 h-6 mr-2 ${convertColorToTextClass(colorOption)}`}
weight="fill"
/>
{colorOption}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-4">
<div className="flex-1">
<Select onValueChange={setCustomAgentColor} defaultValue={customAgentColor}>
<SelectTrigger className="w-full dark:bg-muted" disabled={isCreating}>
<SelectValue placeholder="Color" />
</SelectTrigger>
<SelectContent className="items-center space-y-1 inline-flex flex-col">
{colorOptions.map((colorOption) => (
<SelectItem key={colorOption} value={colorOption}>
<div className="flex items-center space-x-2">
<Circle
className={`w-6 h-6 mr-2 ${convertColorToTextClass(colorOption)}`}
weight="fill"
/>
{colorOption}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select onValueChange={setCustomAgentIcon} defaultValue={customAgentIcon}>
<SelectTrigger className="w-full dark:bg-muted" disabled={isCreating}>
<SelectValue placeholder="Icon" />
</SelectTrigger>
<SelectContent className="items-center space-y-1 inline-flex flex-col">
{iconOptions.map((iconOption) => (
<SelectItem key={iconOption} value={iconOption}>
<div className="flex items-center space-x-2">
{getIconFromIconName(
iconOption,
customAgentColor ?? "gray",
"w-6",
"h-6",
)}
{iconOption}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select
onValueChange={setCustomAgentIcon}
defaultValue={customAgentIcon}
>
<SelectTrigger
className="w-full dark:bg-muted"
disabled={isCreating}
>
<SelectValue placeholder="Icon" />
</SelectTrigger>
<SelectContent className="items-center space-y-1 inline-flex flex-col">
{iconOptions.map((iconOption) => (
<SelectItem key={iconOption} value={iconOption}>
<div className="flex items-center space-x-2">
{getIconFromIconName(
iconOption,
customAgentColor ?? "gray",
"w-6",
"h-6",
)}
{iconOption}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
}
</div>
)}
</div>
<DialogFooter>
{
error && (
<div className="text-red-500 text-sm">
{error}
</div>
)
}
{
!doneCreating && (
<Button
type="submit"
onClick={() => createAgent()}
disabled={isCreating || !isValid}
>
{
isCreating ?
<CircleNotch className="animate-spin" />
:
<PersonSimpleTaiChi />
}
Create
</Button>
)
}
{error && <div className="text-red-500 text-sm">{error}</div>}
{!doneCreating && (
<Button
type="submit"
onClick={() => createAgent()}
disabled={isCreating || !isValid}
>
{isCreating ? (
<CircleNotch className="animate-spin" />
) : (
<PersonSimpleTaiChi />
)}
Create
</Button>
)}
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog >
)
</Dialog>
);
}
function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
@@ -305,7 +326,14 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
const { data: agentConfigurationOptions, error: agentConfigurationOptionsError } =
useSWR<AgentConfigurationOptions>("/api/agents/options", fetcher);
const { data: agentData, isLoading: agentDataLoading, error: agentDataError } = useSWR<AgentData>(`/api/agents/conversation?conversation_id=${props.conversationId}`, fetcher);
const {
data: agentData,
isLoading: agentDataLoading,
error: agentDataError,
} = useSWR<AgentData>(
`/api/agents/conversation?conversation_id=${props.conversationId}`,
fetcher,
);
const {
data: authenticatedData,
error: authenticationError,
@@ -317,7 +345,9 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
const [inputTools, setInputTools] = useState<string[] | undefined>();
const [outputModes, setOutputModes] = useState<string[] | undefined>();
const [hasModified, setHasModified] = useState<boolean>(false);
const [isDefaultAgent, setIsDefaultAgent] = useState<boolean>(!agentData || agentData?.slug?.toLowerCase() === "khoj");
const [isDefaultAgent, setIsDefaultAgent] = useState<boolean>(
!agentData || agentData?.slug?.toLowerCase() === "khoj",
);
const [displayInputTools, setDisplayInputTools] = useState<string[] | undefined>();
const [displayOutputModes, setDisplayOutputModes] = useState<string[] | undefined>();
@@ -330,12 +360,20 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
setInputTools(agentData.input_tools);
setDisplayInputTools(agentData.input_tools);
if (agentData.input_tools === undefined || agentData.input_tools.length === 0) {
setDisplayInputTools(agentConfigurationOptions?.input_tools ? Object.keys(agentConfigurationOptions.input_tools) : []);
setDisplayInputTools(
agentConfigurationOptions?.input_tools
? Object.keys(agentConfigurationOptions.input_tools)
: [],
);
}
setOutputModes(agentData.output_modes);
setDisplayOutputModes(agentData.output_modes);
if (agentData.output_modes === undefined || agentData.output_modes.length === 0) {
setDisplayOutputModes(agentConfigurationOptions?.output_modes ? Object.keys(agentConfigurationOptions.output_modes) : []);
setDisplayOutputModes(
agentConfigurationOptions?.output_modes
? Object.keys(agentConfigurationOptions.output_modes)
: [],
);
}
if (agentData.name?.toLowerCase() === "khoj" || agentData.is_hidden === true) {
@@ -367,8 +405,12 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
const promptChanged = !!customPrompt && customPrompt !== agentData.persona;
// Order independent check to ensure input tools or output modes haven't been changed.
const toolsChanged = JSON.stringify(inputTools?.sort() || []) !== JSON.stringify(agentData.input_tools?.sort());
const modesChanged = JSON.stringify(outputModes?.sort() || []) !== JSON.stringify(agentData.output_modes?.sort());
const toolsChanged =
JSON.stringify(inputTools?.sort() || []) !==
JSON.stringify(agentData.input_tools?.sort());
const modesChanged =
JSON.stringify(outputModes?.sort() || []) !==
JSON.stringify(agentData.output_modes?.sort());
setHasModified(modelChanged || promptChanged || toolsChanged || modesChanged);
@@ -394,7 +436,9 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
function handleSave() {
if (hasModified) {
if (!isDefaultAgent && agentData?.is_hidden === false) {
alert("This agent is not a hidden agent. It cannot be modified from this interface.");
alert(
"This agent is not a hidden agent. It cannot be modified from this interface.",
);
return;
}
@@ -409,12 +453,14 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
chat_model: selectedModel,
input_tools: inputTools,
output_modes: outputModes,
...(isDefaultAgent ? {} : { slug: agentData?.slug })
...(isDefaultAgent ? {} : { slug: agentData?.slug }),
};
setIsSaving(true);
const url = !isDefaultAgent ? `/api/agents/hidden` : `/api/agents/hidden?conversation_id=${props.conversationId}`;
const url = !isDefaultAgent
? `/api/agents/hidden`
: `/api/agents/hidden?conversation_id=${props.conversationId}`;
// There are four scenarios here.
// 1. If the agent is a default agent, then we need to create a new agent just to associate with this conversation.
@@ -424,13 +470,13 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
fetch(url, {
method: mode,
headers: {
"Content-Type": "application/json"
"Content-Type": "application/json",
},
body: JSON.stringify(data)
body: JSON.stringify(data),
})
.then((res) => {
setIsSaving(false);
res.json()
res.json();
})
.then((data) => {
mutate(`/api/agents/conversation?conversation_id=${props.conversationId}`);
@@ -456,43 +502,47 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
<Sidebar
collapsible="none"
className={`ml-auto opacity-30 rounded-lg p-2 transition-all transform duration-300 ease-in-out
${props.isOpen
? "translate-x-0 opacity-100 w-[300px] relative"
: "translate-x-full opacity-100 w-0 p-0 m-0"}
${
props.isOpen
? "translate-x-0 opacity-100 w-[300px] relative"
: "translate-x-full opacity-100 w-0 p-0 m-0"
}
`}
variant="floating">
variant="floating"
>
<SidebarContent>
<SidebarHeader>
{
agentData && !isEditable ? (
<div className="flex items-center relative text-sm">
<a className="text-lg font-bold flex flex-row items-center" href={`/agents?agent=${agentData.slug}`}>
{getIconFromIconName(agentData.icon, agentData.color)}
{agentData.name}
</a>
</div>
) : (
<div className="flex items-center relative text-sm justify-between">
<p>
Chat Options
</p>
</div>
)
}
{agentData && !isEditable ? (
<div className="flex items-center relative text-sm">
<a
className="text-lg font-bold flex flex-row items-center"
href={`/agents?agent=${agentData.slug}`}
>
{getIconFromIconName(agentData.icon, agentData.color)}
{agentData.name}
</a>
</div>
) : (
<div className="flex items-center relative text-sm justify-between">
<p>Chat Options</p>
</div>
)}
</SidebarHeader>
<SidebarGroup key={"knowledge"} className="border-b last:border-none">
<SidebarGroupContent className="gap-0">
<SidebarMenu className="p-0 m-0">
{
agentData && agentData.has_files ? (
<SidebarMenuItem key={"agent_knowledge"} className="list-none">
<div className="flex items-center space-x-2 rounded-full">
<div className="text-muted-foreground"><Sparkle /></div>
<div className="text-muted-foreground text-sm">Using custom knowledge base</div>
{agentData && agentData.has_files ? (
<SidebarMenuItem key={"agent_knowledge"} className="list-none">
<div className="flex items-center space-x-2 rounded-full">
<div className="text-muted-foreground">
<Sparkle />
</div>
</SidebarMenuItem>
) : null
}
<div className="text-muted-foreground text-sm">
Using custom knowledge base
</div>
</div>
</SidebarMenuItem>
) : null}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -506,39 +556,41 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
value={customPrompt || ""}
onChange={(e) => handleCustomPromptChange(e.target.value)}
readOnly={!isEditable}
disabled={!isEditable} />
disabled={!isEditable}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{
!agentDataLoading && agentData && (
<SidebarGroup key={"model"}>
<SidebarGroupContent>
<SidebarGroupLabel>
Model
{
!isSubscribed && (
<a href="/settings" className="hover:font-bold text-accent-foreground m-2 bg-accent bg-opacity-10 p-1 rounded-lg">
Upgrade
</a>
)
}
</SidebarGroupLabel>
<SidebarMenu className="p-0 m-0">
<SidebarMenuItem key={"model"} className="list-none">
<ModelSelector
disabled={!isEditable}
onSelect={(model) => handleModelSelect(model.name)}
initialModel={isDefaultAgent ? undefined : agentData?.chat_model}
isActive={props.isActive}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}
{!agentDataLoading && agentData && (
<SidebarGroup key={"model"}>
<SidebarGroupContent>
<SidebarGroupLabel>
Model
{!isSubscribed && (
<a
href="/settings"
className="hover:font-bold text-accent-foreground m-2 bg-accent bg-opacity-10 p-1 rounded-lg"
>
Upgrade
</a>
)}
</SidebarGroupLabel>
<SidebarMenu className="p-0 m-0">
<SidebarMenuItem key={"model"} className="list-none">
<ModelSelector
disabled={!isEditable}
onSelect={(model) => handleModelSelect(model.name)}
initialModel={
isDefaultAgent ? undefined : agentData?.chat_model
}
isActive={props.isActive}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
<Popover defaultOpen={false}>
<SidebarGroup>
<SidebarGroupLabel asChild>
@@ -550,82 +602,118 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
<PopoverContent>
<SidebarGroupContent>
<SidebarMenu className="p-1 m-0">
{
Object.entries(agentConfigurationOptions?.input_tools ?? {}).map(([key, value]) => {
return (
<SidebarMenuItem key={key} className="list-none">
<Tooltip>
<TooltipTrigger key={key} asChild>
<div className="flex items-center space-x-2 py-1 justify-between">
<Label htmlFor={key} className="flex items-center gap-2 text-accent-foreground p-1 cursor-pointer">
{getIconForSlashCommand(key)}
<p className="text-sm my-auto flex items-center">
{key}
</p>
</Label>
<Checkbox
id={key}
className={`${isEditable ? "cursor-pointer" : ""}`}
checked={isValueChecked(key, displayInputTools ?? [])}
onCheckedChange={() => {
let updatedInputTools = handleCheckToggle(key, displayInputTools ?? [])
setInputTools(updatedInputTools);
setDisplayInputTools(updatedInputTools);
}}
disabled={!isEditable}
>
{Object.entries(
agentConfigurationOptions?.input_tools ?? {},
).map(([key, value]) => {
return (
<SidebarMenuItem key={key} className="list-none">
<Tooltip>
<TooltipTrigger key={key} asChild>
<div className="flex items-center space-x-2 py-1 justify-between">
<Label
htmlFor={key}
className="flex items-center gap-2 text-accent-foreground p-1 cursor-pointer"
>
{getIconForSlashCommand(key)}
<p className="text-sm my-auto flex items-center">
{key}
</Checkbox>
</div>
</TooltipTrigger>
<TooltipContent sideOffset={5} side="left" align="start" className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg">
{value}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}
)
}
{
Object.entries(agentConfigurationOptions?.output_modes ?? {}).map(([key, value]) => {
return (
<SidebarMenuItem key={key} className="list-none">
<Tooltip>
<TooltipTrigger key={key} asChild>
<div className="flex items-center space-x-2 py-1 justify-between">
<Label htmlFor={key} className="flex items-center gap-2 p-1 rounded-lg cursor-pointer">
{getIconForSlashCommand(key)}
<p className="text-sm my-auto flex items-center">
{key}
</p>
</Label>
<Checkbox
id={key}
className={`${isEditable ? "cursor-pointer" : ""}`}
checked={isValueChecked(key, displayOutputModes ?? [])}
onCheckedChange={() => {
let updatedOutputModes = handleCheckToggle(key, displayOutputModes ?? [])
setOutputModes(updatedOutputModes);
setDisplayOutputModes(updatedOutputModes);
}}
disabled={!isEditable}
>
</p>
</Label>
<Checkbox
id={key}
className={`${isEditable ? "cursor-pointer" : ""}`}
checked={isValueChecked(
key,
displayInputTools ?? [],
)}
onCheckedChange={() => {
let updatedInputTools =
handleCheckToggle(
key,
displayInputTools ?? [],
);
setInputTools(
updatedInputTools,
);
setDisplayInputTools(
updatedInputTools,
);
}}
disabled={!isEditable}
>
{key}
</Checkbox>
</div>
</TooltipTrigger>
<TooltipContent
sideOffset={5}
side="left"
align="start"
className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg"
>
{value}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
})}
{Object.entries(
agentConfigurationOptions?.output_modes ?? {},
).map(([key, value]) => {
return (
<SidebarMenuItem key={key} className="list-none">
<Tooltip>
<TooltipTrigger key={key} asChild>
<div className="flex items-center space-x-2 py-1 justify-between">
<Label
htmlFor={key}
className="flex items-center gap-2 p-1 rounded-lg cursor-pointer"
>
{getIconForSlashCommand(key)}
<p className="text-sm my-auto flex items-center">
{key}
</Checkbox>
</div>
</TooltipTrigger>
<TooltipContent sideOffset={5} side="left" align="start" className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg">
{value}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
}
)
}
</p>
</Label>
<Checkbox
id={key}
className={`${isEditable ? "cursor-pointer" : ""}`}
checked={isValueChecked(
key,
displayOutputModes ?? [],
)}
onCheckedChange={() => {
let updatedOutputModes =
handleCheckToggle(
key,
displayOutputModes ??
[],
);
setOutputModes(
updatedOutputModes,
);
setDisplayOutputModes(
updatedOutputModes,
);
}}
disabled={!isEditable}
>
{key}
</Checkbox>
</div>
</TooltipTrigger>
<TooltipContent
sideOffset={5}
side="left"
align="start"
className="text-sm bg-background text-foreground shadow-sm border border-slate-500 border-opacity-20 p-2 rounded-lg"
>
{value}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</PopoverContent>
</SidebarGroup>
@@ -645,79 +733,75 @@ function ChatSidebarInternal({ ...props }: ChatSideBarProps) {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{
props.isOpen && (
<SidebarFooter key={"actions"}>
<SidebarMenu className="p-0 m-0">
{
(agentData && !isEditable && agentData.is_creator) ? (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
className="w-full"
variant={"ghost"}
onClick={() => window.location.href = `/agents?agent=${agentData?.slug}`}
>
Manage
</Button>
</SidebarMenuButton>
</SidebarMenuItem>
) :
<>
{
!hasModified && isEditable && customPrompt && !isDefaultAgent && selectedModel && (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<AgentCreationForm
customPrompt={customPrompt}
selectedModel={selectedModel}
inputTools={displayInputTools ?? []}
outputModes={displayOutputModes ?? []}
/>
</SidebarMenuButton>
</SidebarMenuItem>
)
{props.isOpen && (
<SidebarFooter key={"actions"}>
<SidebarMenu className="p-0 m-0">
{agentData && !isEditable && agentData.is_creator ? (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
className="w-full"
variant={"ghost"}
onClick={() =>
(window.location.href = `/agents?agent=${agentData?.slug}`)
}
>
Manage
</Button>
</SidebarMenuButton>
</SidebarMenuItem>
) : (
<>
{!hasModified &&
isEditable &&
customPrompt &&
!isDefaultAgent &&
selectedModel && (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
className="w-full"
onClick={() => handleReset()}
variant={"ghost"}
disabled={!isEditable || !hasModified}
>
Reset
</Button>
<AgentCreationForm
customPrompt={customPrompt}
selectedModel={selectedModel}
inputTools={displayInputTools ?? []}
outputModes={displayOutputModes ?? []}
/>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
className={`w-full ${hasModified ? "bg-accent-foreground text-accent" : ""}`}
variant={"secondary"}
onClick={() => handleSave()}
disabled={!isEditable || !hasModified || isSaving}
>
{
isSaving ?
<CircleNotch className="animate-spin" />
:
<ArrowsDownUp />
}
{
isSaving ? "Saving" : "Save"
}
</Button>
</SidebarMenuButton>
</SidebarMenuItem>
</>
}
</SidebarMenu>
</SidebarFooter>
)
}
)}
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
className="w-full"
onClick={() => handleReset()}
variant={"ghost"}
disabled={!isEditable || !hasModified}
>
Reset
</Button>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
className={`w-full ${hasModified ? "bg-accent-foreground text-accent" : ""}`}
variant={"secondary"}
onClick={() => handleSave()}
disabled={!isEditable || !hasModified || isSaving}
>
{isSaving ? (
<CircleNotch className="animate-spin" />
) : (
<ArrowsDownUp />
)}
{isSaving ? "Saving" : "Save"}
</Button>
</SidebarMenuButton>
</SidebarMenuItem>
</>
)}
</SidebarMenu>
</SidebarFooter>
)}
</Sidebar>
)
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { X, Warning } from "@phosphor-icons/react";
import { useUserConfig } from "@/app/common/auth";
const DISMISS_KEY = "khoj-cloud-deprecation-dismissed";
export function DeprecationBanner() {
const [isDismissed, setIsDismissed] = useState(true);
const { data: userConfig } = useUserConfig(true);
useEffect(() => {
setIsDismissed(localStorage.getItem(DISMISS_KEY) === "true");
}, []);
function dismiss() {
localStorage.setItem(DISMISS_KEY, "true");
setIsDismissed(true);
}
if (isDismissed || !userConfig?.billing_enabled) return null;
return (
<div className="w-full px-4 py-2.5 bg-orange-600/90 shadow-sm flex items-center justify-center gap-2 text-sm text-orange-50 z-0 relative">
<Warning className="h-4 w-4 shrink-0 text-orange-200" weight="bold" />
<p>
<strong>Khoj Cloud is being deprecated on April 15, 2026.</strong>{" "}
Please{" "}
<Link href="/settings#account" className="underline font-medium hover:text-white">
export your data
</Link>
{" "}before then. To continue using Khoj, you can{" "}
<a
href="https://docs.khoj.dev/get-started/setup"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-white"
>
self-host it
</a>.
</p>
<button
onClick={dismiss}
className="ml-2 p-0.5 rounded hover:bg-orange-700 shrink-0"
aria-label="Dismiss banner"
>
<X className="h-4 w-4" />
</button>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import Link from "next/link";
import { CircleNotch } from "@phosphor-icons/react";
import { AppSidebar } from "../appSidebar/appSidebar";
import { Separator } from "@/components/ui/separator";
@@ -23,9 +24,9 @@ export default function Loading(props: LoadingProps) {
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
<h2 className="text-lg">Ask Anything</h2>
)}

View File

@@ -147,9 +147,7 @@ const Mermaid: React.FC<MermaidProps> = ({ chart }) => {
<span>{mermaidError}</span>
</div>
<code className="block bg-secondary text-secondary-foreground p-4 mt-3 rounded-lg font-mono text-sm whitespace-pre-wrap overflow-x-auto max-h-[400px] border border-gray-200">
{
chart
}
{chart}
</code>
</>
) : (

View File

@@ -12,7 +12,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Moon, Sun, UserCircle, Question, ArrowRight, Code, BuildingOffice } from "@phosphor-icons/react";
import {
Moon,
Sun,
UserCircle,
Question,
ArrowRight,
Code,
BuildingOffice,
} from "@phosphor-icons/react";
import { useIsDarkMode, useIsMobileWidth } from "@/app/common/utils";
import LoginPrompt from "../loginPrompt/loginPrompt";
import { Button } from "@/components/ui/button";
@@ -69,7 +77,7 @@ export default function FooterMenu({ sideBarIsOpen }: NavMenuProps) {
icon: <BuildingOffice className="w-6 h-6" />,
link: "https://khoj.dev/teams",
},
]
];
return (
<SidebarMenu className="border-none p-0 m-0">
@@ -131,18 +139,16 @@ export default function FooterMenu({ sideBarIsOpen }: NavMenuProps) {
</p>
</div>
</DropdownMenuItem>
{
menuItems.map((menuItem, index) => (
<DropdownMenuItem key={index}>
<Link href={menuItem.link} className="no-underline w-full">
<div className="flex flex-rows">
{menuItem.icon}
<p className="ml-3 font-semibold">{menuItem.title}</p>
</div>
</Link>
</DropdownMenuItem>
))
}
{menuItems.map((menuItem, index) => (
<DropdownMenuItem key={index}>
<Link href={menuItem.link} className="no-underline w-full">
<div className="flex flex-rows">
{menuItem.icon}
<p className="ml-3 font-semibold">{menuItem.title}</p>
</div>
</Link>
</DropdownMenuItem>
))}
{!userData ? (
<DropdownMenuItem>
<Button

View File

@@ -1,6 +1,6 @@
'use client'
"use client";
import { useIsDarkMode } from '@/app/common/utils'
import { useIsDarkMode } from "@/app/common/utils";
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [darkMode, setDarkMode] = useIsDarkMode();

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { ArrowCircleDown, ArrowRight, Code, Note } from "@phosphor-icons/react";
import { ArrowCircleDown, ArrowRight, Code, Note, Clipboard, Check } from "@phosphor-icons/react";
import markdownIt from "markdown-it";
const md = new markdownIt({
@@ -32,6 +32,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import DOMPurify from "dompurify";
import { getIconFromFilename } from "@/app/common/iconUtils";
import Link from "next/link";
import { Button } from "@/components/ui/button";
interface NotesContextReferenceData {
title: string;
@@ -467,9 +468,12 @@ export function constructAllReferences(
if (contextData) {
let localContextReferences = contextData.map((context) => {
if (!context.compiled && context.compiled !== "") {
const fileContent = context as unknown as string;
const title = fileContent.split("\n")[0];
const content = fileContent.split("\n").slice(1).join("\n");
const raw = context as unknown;
const fileContent = typeof raw === "string" ? raw : raw == null ? "" : String(raw);
const lines = fileContent.split("\n");
const title = lines[0] && lines[0].trim() ? lines[0] : "(untitled)";
const content = lines.slice(1).join("\n");
return {
title: title,
content: content,
@@ -491,6 +495,18 @@ export function constructAllReferences(
};
}
export function formatReferencesAsMarkdown(
notesReferenceCardData: NotesContextReferenceData[],
onlineReferenceCardData: OnlineReferenceData[],
codeReferenceCardData: CodeReferenceData[],
): string {
return [
...notesReferenceCardData.map((note) => `- ${note.title}`),
...onlineReferenceCardData.map((online) => `- [${online.title}](${online.link})`),
...codeReferenceCardData.map((_, index) => `- Code Reference ${index + 1}`),
].join("\n");
}
interface SimpleIconProps {
type: string;
link?: string;
@@ -586,10 +602,20 @@ interface ReferencePanelDataProps {
export default function ReferencePanel(props: ReferencePanelDataProps) {
const [numTeaserSlots, setNumTeaserSlots] = useState(3);
const [copyReferencesSuccess, setCopyReferencesSuccess] = useState(false);
useEffect(() => {
setNumTeaserSlots(props.isMobileWidth ? 3 : 5);
}, [props.isMobileWidth]);
useEffect(() => {
if (copyReferencesSuccess) {
setTimeout(() => {
setCopyReferencesSuccess(false);
}, 1000);
}
}, [copyReferencesSuccess]);
if (!props.notesReferenceCardData && !props.onlineReferenceCardData) {
return null;
}
@@ -606,6 +632,17 @@ export default function ReferencePanel(props: ReferencePanelDataProps) {
.slice(0, numTeaserSlots - codeDataToShow.length - notesDataToShow.length)
: [];
const copyReferencesToClipboard = () => {
navigator.clipboard.writeText(
formatReferencesAsMarkdown(
props.notesReferenceCardData,
props.onlineReferenceCardData,
props.codeReferenceCardData,
),
);
setCopyReferencesSuccess(true);
};
return (
<Sheet>
<SheetTrigger className="text-balance w-auto justify-start overflow-hidden break-words p-0 bg-transparent border-none text-gray-400 align-middle items-center m-0 inline-flex">
@@ -642,6 +679,23 @@ export default function ReferencePanel(props: ReferencePanelDataProps) {
<SheetHeader>
<SheetTitle>References</SheetTitle>
<SheetDescription>View all references for this response</SheetDescription>
<Button
variant="outline"
onClick={copyReferencesToClipboard}
className="mt-4"
>
{copyReferencesSuccess ? (
<>
<Check className="mr-2 text-green-500" />
Copied!
</>
) : (
<>
<Clipboard className="mr-2" />
Copy References
</>
)}
</Button>
</SheetHeader>
<div className="flex flex-wrap gap-2 w-auto mt-2">
{props.codeReferenceCardData.map((code, index) => {

View File

@@ -0,0 +1,90 @@
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Pencil, TrashSimple, FloppyDisk, X } from "@phosphor-icons/react";
import { useToast } from "@/components/ui/use-toast";
export interface UserMemorySchema {
id: number;
raw: string;
created_at: string;
}
interface UserMemoryProps {
memory: UserMemorySchema;
onDelete: (id: number) => void;
onUpdate: (id: number, raw: string) => void;
}
export function UserMemory({ memory, onDelete, onUpdate }: UserMemoryProps) {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(memory.raw);
const { toast } = useToast();
const handleUpdate = () => {
onUpdate(memory.id, content);
setIsEditing(false);
toast({
title: "Memory Updated",
description: "Your memory has been successfully updated.",
});
};
const handleDelete = () => {
onDelete(memory.id);
toast({
title: "Memory Deleted",
description: "Your memory has been successfully deleted.",
});
};
return (
<div className="flex items-center gap-2 w-full">
{isEditing ? (
<>
<Input
value={content}
onChange={(e) => setContent(e.target.value)}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={handleUpdate}
title="Save"
>
<FloppyDisk className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditing(false)}
title="Cancel"
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<Input value={memory.raw} readOnly className="flex-1" />
<Button
variant="ghost"
size="icon"
onClick={() => setIsEditing(true)}
title="Edit"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
title="Delete"
>
<TrashSimple className="h-4 w-4" />
</Button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,366 @@
/* Hide print-only elements on screen */
.print-only-header {
display: none;
}
/* Print-specific styles for clean PDF export */
@media print {
/* Show print-only header */
.print-only-header {
display: block !important;
margin-bottom: 2rem !important;
padding-bottom: 1rem !important;
page-break-after: avoid;
}
.print-header-content {
display: flex !important;
align-items: flex-start !important;
gap: 1.5rem !important;
margin-bottom: 1rem !important;
}
.print-header-left {
flex-shrink: 0 !important;
}
.print-logo {
width: 60px !important;
height: 60px !important;
fill: #000 !important;
}
.print-header-right {
flex: 1 !important;
}
.print-only-header h1 {
font-size: 28pt !important;
font-weight: bold !important;
color: #000 !important;
margin: 0 0 0.75rem 0 !important;
line-height: 1.2 !important;
page-break-after: avoid;
}
.conversation-meta {
display: flex !important;
flex-direction: column !important;
gap: 0.25rem !important;
margin-bottom: 0 !important;
}
.conversation-meta p {
margin: 0 !important;
font-size: 14pt !important;
color: #000 !important;
line-height: 1.3 !important;
}
.conversation-meta strong {
font-weight: bold !important;
color: #000 !important;
}
.print-only-header hr {
border: none !important;
height: 2px !important;
background-color: #000 !important;
margin: 1rem 0 0 0 !important;
width: 100% !important;
}
/* Hide non-essential elements */
.sidebar,
.sidebar-trigger,
.sidebar-inset > header,
.print-hidden,
button,
nav,
[data-sidebar],
[data-sidebar-trigger],
.chat-sidebar,
.app-sidebar {
display: none !important;
}
/* Reset page margins and layout */
@page {
margin: 0.5in;
size: A4;
}
/* Main layout adjustments for print */
body {
font-size: 12pt !important;
line-height: 1.4 !important;
color: #000 !important;
background: white !important;
}
/* Remove background colors and shadows */
* {
background: transparent !important;
box-shadow: none !important;
text-shadow: none !important;
animation: none !important;
transition: none !important;
}
/* Remove any height constraints that could limit content */
div,
section,
main,
article,
aside {
max-height: none !important;
}
/* Specific height fixes for flex containers */
[style*="height"],
[style*="max-height"] {
height: auto !important;
max-height: none !important;
}
/* Ensure all content is visible - target all scroll containers */
.scroll-area,
.scroll-area > div,
[data-radix-scroll-area-viewport],
[data-radix-scroll-area-scrollbar],
[data-radix-scroll-area-content] {
height: auto !important;
max-height: none !important;
overflow: visible !important;
position: static !important;
}
/* Chat history styling */
.chat-history,
.chat-history > *,
.chat-body,
.chat-body-full {
display: block !important;
width: 100% !important;
height: auto !important;
max-height: none !important;
overflow: visible !important;
position: static !important;
}
/* Ensure chat messages container expands fully */
.chat-messages,
.chat-messages-container,
.messages-container {
height: auto !important;
max-height: none !important;
overflow: visible !important;
}
/* Make chat messages use full width in print */
.w-4\/6,
.w-2\/3,
.w-3\/4,
.max-w-2xl,
.max-w-3xl,
.max-w-4xl,
.max-w-5xl {
width: 100% !important;
max-width: none !important;
}
/* Message styling for print */
.chat-message {
page-break-inside: avoid;
margin-bottom: 1rem !important;
padding: 0.5rem !important;
border-bottom: 1px solid #ccc !important;
width: 100% !important;
max-width: none !important;
}
/* Ensure message containers use full width */
.chat-message-container,
.chat-message-wrapper {
width: 100% !important;
max-width: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* Title styling */
h1,
h2,
h3,
h4,
h5,
h6 {
page-break-after: avoid;
color: #000 !important;
font-weight: bold !important;
margin-top: 0.5rem !important;
margin-bottom: 0.5rem !important;
}
/* Code block styling */
pre,
code {
font-family: "Courier New", monospace !important;
font-size: 0.85em !important;
white-space: pre-wrap !important;
word-wrap: break-word !important;
border: 1px solid #ccc !important;
padding: 0.5rem !important;
margin: 0.5rem 0 !important;
background: #f9f9f9 !important;
color: #000 !important;
}
/* Link styling */
a {
color: #000 !important;
text-decoration: underline !important;
}
/* Image handling */
img {
max-width: 100% !important;
height: auto !important;
page-break-inside: avoid;
}
/* Force black text color */
p,
div,
span,
li,
td,
th {
color: #000 !important;
}
/* Lists */
ol,
ul {
margin-left: 1rem !important;
margin-bottom: 0.5rem !important;
}
/* Table styling */
table {
border-collapse: collapse !important;
width: 100% !important;
margin: 0.5rem 0 !important;
}
table,
th,
td {
border: 1px solid #000 !important;
padding: 0.25rem !important;
}
/* Paragraphs */
p {
margin-bottom: 0.5rem !important;
color: #000 !important;
}
/* Ensure proper spacing */
.chat-message + .chat-message {
margin-top: 1rem !important;
}
/* Hide scroll indicators */
::-webkit-scrollbar {
display: none !important;
}
/* Hide interactive elements */
.retry-button,
.action-button,
.chat-footer,
.chat-buttons,
.code-copy-button,
.copy-button,
.feedback-buttons {
display: none !important;
}
/* Train of thought styling for print */
.train-of-thought {
border-left: 2px solid #ccc !important;
margin: 0.5rem 0 !important;
padding: 0.5rem !important;
font-size: 0.9em !important;
color: #666 !important;
}
.train-of-thought strong {
color: #000 !important;
}
.train-of-thought.primary {
border-left-color: #000 !important;
}
.train-of-thought-element {
display: block !important;
margin-bottom: 0.5rem !important;
}
/* Chat message container styling */
.chat-message-container {
background: transparent !important;
border: 1px solid #ccc !important;
border-radius: 8px !important;
margin: 0.5rem 0 !important;
padding: 0.5rem !important;
page-break-inside: avoid;
}
.chat-message-wrapper {
padding-left: 0.5rem !important;
padding-bottom: 0.5rem !important;
}
.you {
background-color: #f5f5f5 !important;
color: #000 !important;
}
.khoj {
background-color: transparent !important;
color: #000 !important;
}
.author {
color: #666 !important;
font-size: 0.7rem !important;
}
/* Image containers */
.images-container {
display: block !important;
overflow: visible !important;
margin-bottom: 0.5rem !important;
}
.image-wrapper {
margin-right: 0 !important;
margin-bottom: 0.5rem !important;
}
.image-wrapper img {
width: auto !important;
height: auto !important;
max-width: 100% !important;
max-height: 4in !important;
object-fit: contain !important;
page-break-inside: avoid;
}
/* Agent indicators */
.agent-indicator {
margin-bottom: 0.5rem !important;
}
}

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { noto_sans, noto_sans_arabic } from "@/app/fonts";
import "./globals.css";
import "./globals-print.css";
import { ContentSecurityPolicy } from "./common/layoutHelper";
import { ThemeProvider } from "./components/providers/themeProvider";

View File

@@ -40,11 +40,13 @@ import { AgentData } from "@/app/components/agentCard/agentCard";
import { createNewConversation } from "./common/chatFunctions";
import { useDebounce, useIsMobileWidth } from "./common/utils";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { AgentCard } from "@/app/components/agentCard/agentCard";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import LoginPopup from "./components/loginPrompt/loginPopup";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { DeprecationBanner } from "@/app/components/deprecationBanner";
import { AppSidebar } from "./components/appSidebar/appSidebar";
import { Separator } from "@/components/ui/separator";
import { KhojLogoType } from "./components/logo/khojLogo";
@@ -566,13 +568,14 @@ export default function Home() {
<SidebarProvider>
<AppSidebar conversationId={conversationId} />
<SidebarInset>
<DeprecationBanner />
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
<h2 className="text-lg">Ask Anything</h2>
)}

View File

@@ -508,7 +508,7 @@ function FileFilterComboBox(props: FileFilterComboBoxProps) {
}, [open]);
return (
<Popover open={open || (noMatchingFiles && (!!inputText))} onOpenChange={setOpen}>
<Popover open={open || (noMatchingFiles && !!inputText)} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -521,14 +521,18 @@ function FileFilterComboBox(props: FileFilterComboBoxProps) {
? "✔️"
: "Selected"
: props.isMobileWidth
? " "
: "Select file"}
? " "
: "Select file"}
<Funnel className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search files..." value={inputText} onInput={(e) => setInputText(e.currentTarget.value)} />
<CommandInput
placeholder="Search files..."
value={inputText}
onInput={(e) => setInputText(e.currentTarget.value)}
/>
<CommandList>
<CommandEmpty>No files found.</CommandEmpty>
<CommandGroup>
@@ -614,7 +618,6 @@ export default function Search() {
setSelectedFileFilter("INITIALIZE");
}
}
}, [searchQuery]);
function handleSearchInputChange(value: string) {
@@ -775,9 +778,9 @@ export default function Search() {
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
<h2 className="text-lg">Search Your Knowledge Base</h2>
)}

View File

@@ -1,12 +1,13 @@
"use client";
import Link from "next/link";
import styles from "./settings.module.css";
import "intl-tel-input/styles";
import { Suspense, useEffect, useState } from "react";
import { useToast } from "@/components/ui/use-toast";
import { useUserConfig, ModelOptions, UserConfig, SubscriptionStates } from "../common/auth";
import { useUserConfig, ModelOptions, UserConfig, SubscriptionStates, isUserSubscribed } from "../common/auth";
import { toTitleCase, useIsMobileWidth } from "../common/utils";
import { isValidPhoneNumber } from "libphonenumber-js";
@@ -15,6 +16,8 @@ import { Button } from "@/components/ui/button";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,9 +26,25 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import {
@@ -66,13 +85,15 @@ import Loading from "../components/loading/loading";
import IntlTelInput from "intl-tel-input/react";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { DeprecationBanner } from "@/app/components/deprecationBanner";
import { AppSidebar } from "../components/appSidebar/appSidebar";
import { UserMemory, UserMemorySchema } from "../components/userMemory/userMemory";
import { Separator } from "@/components/ui/separator";
import { KhojLogoType } from "../components/logo/khojLogo";
import { Progress } from "@/components/ui/progress";
import JSZip from "jszip";
import { saveAs } from 'file-saver';
import { saveAs } from "file-saver";
interface DropdownComponentProps {
items: ModelOptions[];
@@ -81,7 +102,12 @@ interface DropdownComponentProps {
callbackFunc: (value: string) => Promise<void>;
}
const DropdownComponent: React.FC<DropdownComponentProps> = ({ items, selected, isActive, callbackFunc }) => {
const DropdownComponent: React.FC<DropdownComponentProps> = ({
items,
selected,
isActive,
callbackFunc,
}) => {
const [position, setPosition] = useState(selected?.toString() ?? "0");
return (
@@ -114,7 +140,10 @@ const DropdownComponent: React.FC<DropdownComponentProps> = ({ items, selected,
value={item.id.toString()}
disabled={!isActive && item.tier !== "free"}
>
{item.name} {item.tier === "standard" && <span className="text-green-500 ml-2">(Futurist)</span>}
{item.name}{" "}
{item.tier === "standard" && (
<span className="text-green-500 ml-2">(Futurist)</span>
)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@@ -308,6 +337,9 @@ export default function SettingsView() {
const [numberValidationState, setNumberValidationState] = useState<PhoneNumberValidationState>(
PhoneNumberValidationState.Verified,
);
const [memories, setMemories] = useState<UserMemorySchema[]>([]);
const [enableMemory, setEnableMemory] = useState<boolean>(true);
const [serverMemoryMode, setServerMemoryMode] = useState<string>("enabled_default_on");
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [exportedConversations, setExportedConversations] = useState(0);
@@ -327,11 +359,13 @@ export default function SettingsView() {
initialUserConfig?.is_phone_number_verified
? PhoneNumberValidationState.Verified
: initialUserConfig?.phone_number
? PhoneNumberValidationState.SendOTP
: PhoneNumberValidationState.Setup,
? PhoneNumberValidationState.SendOTP
: PhoneNumberValidationState.Setup,
);
setName(initialUserConfig?.given_name);
setNotionToken(initialUserConfig?.notion_token ?? null);
setEnableMemory(initialUserConfig?.enable_memory ?? true);
setServerMemoryMode(initialUserConfig?.server_memory_mode ?? "enabled_default_on");
}, [initialUserConfig]);
const sendOTP = async () => {
@@ -445,51 +479,6 @@ export default function SettingsView() {
}
};
const enableFreeTrial = async () => {
const formatDate = (dateString: Date) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
};
try {
const response = await fetch(`/api/subscription/trial`, {
method: "POST",
});
if (!response.ok) throw new Error("Failed to enable free trial");
const responseBody = await response.json();
// Set updated user settings
if (responseBody.trial_enabled && userConfig) {
let newUserConfig = userConfig;
newUserConfig.subscription_state = SubscriptionStates.TRIAL;
const renewalDate = new Date(
Date.now() + userConfig.length_of_free_trial * 24 * 60 * 60 * 1000,
);
newUserConfig.subscription_renewal_date = formatDate(renewalDate);
newUserConfig.subscription_enabled_trial_at = new Date().toISOString();
setUserConfig(newUserConfig);
// Notify user of free trial
toast({
title: "🎉 Trial Enabled",
description: `Your free trial will end on ${newUserConfig.subscription_renewal_date}`,
});
}
} catch (error) {
console.error("Error enabling free trial:", error);
toast({
title: "⚠️ Failed to Enable Free Trial",
description:
"Failed to enable free trial. Try again or contact us at team@khoj.dev",
});
}
};
const saveName = async () => {
if (!name) return;
try {
@@ -524,13 +513,14 @@ export default function SettingsView() {
const updateModel = (modelType: string) => async (id: string) => {
// Get the selected model from the options
const modelOptions = modelType === "chat"
? userConfig?.chat_model_options
: modelType === "paint"
? userConfig?.paint_model_options
: userConfig?.voice_model_options;
const modelOptions =
modelType === "chat"
? userConfig?.chat_model_options
: modelType === "paint"
? userConfig?.paint_model_options
: userConfig?.voice_model_options;
const selectedModel = modelOptions?.find(model => model.id.toString() === id);
const selectedModel = modelOptions?.find((model) => model.id.toString() === id);
const modelName = selectedModel?.name;
// Check if the model is free tier or if the user is active
@@ -551,7 +541,8 @@ export default function SettingsView() {
},
});
if (!response.ok) throw new Error(`Failed to switch ${modelType} model to ${modelName}`);
if (!response.ok)
throw new Error(`Failed to switch ${modelType} model to ${modelName}`);
toast({
title: `✅ Switched ${modelType} model to ${modelName}`,
@@ -570,7 +561,7 @@ export default function SettingsView() {
setIsExporting(true);
// Get total conversation count
const statsResponse = await fetch('/api/chat/stats');
const statsResponse = await fetch("/api/chat/stats");
const stats = await statsResponse.json();
const total = stats.num_conversations;
setTotalConversations(total);
@@ -586,7 +577,7 @@ export default function SettingsView() {
conversations.push(...data);
setExportedConversations((page + 1) * 10);
setExportProgress(((page + 1) * 10 / total) * 100);
setExportProgress((((page + 1) * 10) / total) * 100);
}
// Add conversations to zip
@@ -605,7 +596,7 @@ export default function SettingsView() {
toast({
title: "Export Failed",
description: "Failed to export chats. Please try again.",
variant: "destructive"
variant: "destructive",
});
} finally {
setIsExporting(false);
@@ -649,6 +640,88 @@ export default function SettingsView() {
}
};
const fetchMemories = async () => {
try {
console.log("Fetching memories...");
const response = await fetch('/api/memories');
if (!response.ok) throw new Error('Failed to fetch memories');
const data = await response.json();
setMemories(data);
} catch (error) {
console.error('Error fetching memories:', error);
toast({
title: "Error",
description: "Failed to fetch memories. Please try again.",
variant: "destructive"
});
}
};
const handleDeleteMemory = async (id: number) => {
try {
const response = await fetch(`/api/memories/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete memory');
setMemories(memories.filter(memory => memory.id !== id));
} catch (error) {
console.error('Error deleting memory:', error);
toast({
title: "Error",
description: "Failed to delete memory. Please try again.",
variant: "destructive"
});
}
};
const handleUpdateMemory = async (id: number, raw: string) => {
try {
const response = await fetch(`/api/memories/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ raw, memory_id: id }),
});
if (!response.ok) throw new Error('Failed to update memory');
const updatedMemory: UserMemorySchema = await response.json();
setMemories(memories.map(memory =>
memory.id === id ? updatedMemory : memory
));
} catch (error) {
console.error('Error updating memory:', error);
toast({
title: "Error",
description: "Failed to update memory. Please try again.",
variant: "destructive"
});
}
};
const handleToggleMemory = async (enabled: boolean) => {
try {
const response = await fetch(`/api/user/memory?enable_memory=${enabled}`, {
method: 'PATCH',
});
if (!response.ok) throw new Error('Failed to update memory setting');
setEnableMemory(enabled);
toast({
title: enabled ? "Memory enabled" : "Memory disabled",
description: enabled
? "Khoj will learn and remember from your conversations."
: "Khoj will no longer learn or remember from your conversations.",
});
} catch (error) {
console.error('Error toggling memory:', error);
toast({
title: "Error",
description: "Failed to update memory setting. Please try again.",
variant: "destructive"
});
}
};
const syncContent = async (type: string) => {
try {
const response = await fetch(`/api/content?t=${type}`, {
@@ -724,13 +797,14 @@ export default function SettingsView() {
<SidebarProvider>
<AppSidebar conversationId={""} />
<SidebarInset>
<DeprecationBanner />
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
<h2 className="text-lg">Settings</h2>
)}
@@ -775,6 +849,7 @@ export default function SettingsView() {
</Button>
</CardFooter>
</Card>
{isUserSubscribed(userConfig) && (
<Card id="subscription" className={cardClassName}>
<CardHeader className="text-xl flex flex-row">
<CreditCard className="h-7 w-7 mr-2" />
@@ -808,94 +883,66 @@ export default function SettingsView() {
)) ||
(userConfig.subscription_state ===
"subscribed" && (
<>
<p className="text-xl text-primary/80">
Futurist
</p>
<p className="text-gray-400">
Subscription <b>renews</b> on{" "}
<b>
{
userConfig.subscription_renewal_date
}
</b>
</p>
</>
)) ||
<>
<p className="text-xl text-primary/80">
Futurist
</p>
<p className="text-gray-400">
Subscription <b>renews</b> on{" "}
<b>
{
userConfig.subscription_renewal_date
}
</b>
</p>
</>
)) ||
(userConfig.subscription_state ===
"unsubscribed" && (
<>
<p className="text-xl">Futurist</p>
<p className="text-gray-400">
Subscription <b>ends</b> on{" "}
<b>
{
userConfig.subscription_renewal_date
}
</b>
</p>
</>
)) ||
(userConfig.subscription_state ===
"expired" && (
<>
<p className="text-xl">Humanist</p>
{(userConfig.subscription_renewal_date && (
<p className="text-gray-400">
Subscription <b>expired</b>{" "}
on{" "}
<b>
{
userConfig.subscription_renewal_date
}
</b>
</p>
)) || (
<p className="text-gray-400">
Check{" "}
<a
href="https://khoj.dev/#pricing"
target="_blank"
>
pricing page
</a>{" "}
to compare plans.
</p>
)}
</>
))}
<>
<p className="text-xl">Futurist</p>
<p className="text-gray-400">
Subscription <b>ends</b> on{" "}
<b>
{
userConfig.subscription_renewal_date
}
</b>
</p>
</>
))}
</CardContent>
<CardFooter className="flex flex-wrap gap-4">
{(userConfig.subscription_state ==
"subscribed" && (
<Button
variant="outline"
className="hover:text-red-400"
onClick={() =>
setSubscription("cancel")
}
>
<ArrowCircleDown className="h-5 w-5 mr-2" />
Unsubscribe
</Button>
)) ||
<Button
variant="outline"
className="hover:text-red-400"
onClick={() =>
setSubscription("cancel")
}
>
<ArrowCircleDown className="h-5 w-5 mr-2" />
Unsubscribe
</Button>
)) ||
(userConfig.subscription_state ==
"unsubscribed" && (
<Button
variant="outline"
className="text-primary/80 hover:text-primary"
onClick={() =>
setSubscription("resubscribe")
}
>
<ArrowCircleUp
weight="bold"
className="h-5 w-5 mr-2"
/>
Resubscribe
</Button>
)) ||
(userConfig.subscription_enabled_trial_at && (
<Button
variant="outline"
className="text-primary/80 hover:text-primary"
onClick={() =>
setSubscription("resubscribe")
}
>
<ArrowCircleUp
weight="bold"
className="h-5 w-5 mr-2"
/>
Resubscribe
</Button>
)) ||
(
<Button
variant="outline"
className="text-primary/80 hover:text-primary"
@@ -913,21 +960,10 @@ export default function SettingsView() {
/>
Subscribe
</Button>
)) || (
<Button
variant="outline"
className="text-primary/80 hover:text-primary"
onClick={enableFreeTrial}
>
<ArrowCircleUp
weight="bold"
className="h-5 w-5 mr-2"
/>
Enable Trial
</Button>
)}
</CardFooter>
</Card>
)}
</div>
</div>
<div className="section grid gap-8">
@@ -984,16 +1020,16 @@ export default function SettingsView() {
<Button variant="outline" size="sm">
{(userConfig.enabled_content_source
.github && (
<>
<Files className="h-5 w-5 inline mr-1" />
Manage
</>
)) || (
<>
<Plugs className="h-5 w-5 inline mr-1" />
Connect
</>
)}
<>
<Files className="h-5 w-5 inline mr-1" />
Manage
</>
)) || (
<>
<Plugs className="h-5 w-5 inline mr-1" />
Connect
</>
)}
</Button>
<Button
variant="outline"
@@ -1035,8 +1071,8 @@ export default function SettingsView() {
{
/* Show connect to notion button if notion oauth url setup and user disconnected*/
userConfig.notion_oauth_url &&
!userConfig.enabled_content_source
.notion ? (
!userConfig.enabled_content_source
.notion ? (
<Button
variant="outline"
size="sm"
@@ -1050,39 +1086,39 @@ export default function SettingsView() {
Connect
</Button>
) : /* Show sync button if user connected to notion and API key unchanged */
userConfig.enabled_content_source.notion &&
notionToken ===
userConfig.notion_token ? (
<Button
variant="outline"
size="sm"
onClick={() =>
syncContent("notion")
}
>
<ArrowsClockwise className="h-5 w-5 inline mr-1" />
Sync
</Button>
) : /* Show set API key button notion oauth url not set setup */
!userConfig.notion_oauth_url ? (
<Button
variant="outline"
size="sm"
onClick={saveNotionToken}
disabled={
notionToken ===
userConfig.notion_token
}
>
<FloppyDisk className="h-5 w-5 inline mr-1" />
{(userConfig.enabled_content_source
.notion &&
"Update API Key") ||
"Set API Key"}
</Button>
) : (
<></>
)
userConfig.enabled_content_source.notion &&
notionToken ===
userConfig.notion_token ? (
<Button
variant="outline"
size="sm"
onClick={() =>
syncContent("notion")
}
>
<ArrowsClockwise className="h-5 w-5 inline mr-1" />
Sync
</Button>
) : /* Show set API key button notion oauth url not set setup */
!userConfig.notion_oauth_url ? (
<Button
variant="outline"
size="sm"
onClick={saveNotionToken}
disabled={
notionToken ===
userConfig.notion_token
}
>
<FloppyDisk className="h-5 w-5 inline mr-1" />
{(userConfig.enabled_content_source
.notion &&
"Update API Key") ||
"Set API Key"}
</Button>
) : (
<></>
)
}
<Button
variant="outline"
@@ -1123,7 +1159,10 @@ export default function SettingsView() {
<CardFooter className="flex flex-wrap gap-4">
{!userConfig.is_active && (
<p className="text-gray-400">
{userConfig.chat_model_options.some(model => model.tier === "free")
{userConfig.chat_model_options.some(
(model) =>
model.tier === "free",
)
? "Free models available"
: "Subscribe to switch model"}
</p>
@@ -1154,7 +1193,10 @@ export default function SettingsView() {
<CardFooter className="flex flex-wrap gap-4">
{!userConfig.is_active && (
<p className="text-gray-400">
{userConfig.paint_model_options.some(model => model.tier === "free")
{userConfig.paint_model_options.some(
(model) =>
model.tier === "free",
)
? "Free models available"
: "Subscribe to switch model"}
</p>
@@ -1185,7 +1227,10 @@ export default function SettingsView() {
<CardFooter className="flex flex-wrap gap-4">
{!userConfig.is_active && (
<p className="text-gray-400">
{userConfig.voice_model_options.some(model => model.tier === "free")
{userConfig.voice_model_options.some(
(model) =>
model.tier === "free",
)
? "Free models available"
: "Subscribe to switch model"}
</p>
@@ -1204,7 +1249,7 @@ export default function SettingsView() {
</div>
</div>
<div className="section grid gap-8">
<div id="clients" className="text-2xl">
<div id="account" className="text-2xl">
Account
</div>
<div className="cards flex flex-wrap gap-16">
@@ -1219,9 +1264,13 @@ export default function SettingsView() {
</p>
{exportProgress > 0 && (
<div className="w-full mt-4">
<Progress value={exportProgress} className="w-full" />
<Progress
value={exportProgress}
className="w-full"
/>
<p className="text-sm text-gray-500 mt-2">
Exported {exportedConversations} of {totalConversations} conversations
Exported {exportedConversations} of{" "}
{totalConversations} conversations
</p>
</div>
)}
@@ -1233,11 +1282,70 @@ export default function SettingsView() {
disabled={isExporting}
>
<Download className="h-5 w-5 mr-2" />
{isExporting ? "Exporting..." : "Export Chats"}
{isExporting
? "Exporting..."
: "Export Chats"}
</Button>
</CardFooter>
</Card>
<Card className={cardClassName}>
<CardHeader className="text-xl flex flex-row">
<Brain className="h-7 w-7 mr-2" />
Memories
</CardHeader>
<CardContent className="overflow-hidden">
<p className="pb-4 text-gray-400">
View and manage your long-term memories
</p>
<div className="flex items-center justify-between">
<label
htmlFor="enable-memory"
className={`text-sm font-medium leading-none ${serverMemoryMode === "disabled" ? "text-gray-400" : ""}`}
>
Enable Memory
</label>
<Switch
id="enable-memory"
checked={enableMemory}
onCheckedChange={(checked) => handleToggleMemory(checked)}
disabled={serverMemoryMode === "disabled"}
/>
</div>
{serverMemoryMode === "disabled" && (
<p className="text-xs text-gray-400 mt-2">
Memory has been disabled by the server administrator.
</p>
)}
</CardContent>
<CardFooter className="flex flex-wrap gap-4">
<Dialog onOpenChange={(open) => open && fetchMemories()}>
<DialogTrigger asChild>
<Button variant="outline">
<Brain className="h-5 w-5 mr-2" />
Browse Memories
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Your Memories</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
{memories.map((memory) => (
<UserMemory
key={memory.id}
memory={memory}
onDelete={handleDeleteMemory}
onUpdate={handleUpdateMemory}
/>
))}
{memories.length === 0 && (
<p className="text-center text-gray-500">No memories found</p>
)}
</div>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
<Card className={cardClassName}>
<CardHeader className="text-xl flex flex-row">
<TrashSimple className="h-7 w-7 mr-2 text-red-500" />
@@ -1245,7 +1353,11 @@ export default function SettingsView() {
</CardHeader>
<CardContent className="overflow-hidden">
<p className="pb-4 text-gray-400">
This will delete all your account data, including conversations, agents, and any assets you{"'"}ve generated. Be sure to export before you do this if you want to keep your information.
This will delete all your account data,
including conversations, agents, and any
assets you{"'"}ve generated. Be sure to
export before you do this if you want to
keep your information.
</p>
</CardContent>
<CardFooter className="flex flex-wrap gap-4">
@@ -1261,36 +1373,56 @@ export default function SettingsView() {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This action is irreversible. This will permanently delete your account
and remove all your data from our servers.
This action is irreversible.
This will permanently delete
your account and remove all your
data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-500 hover:bg-red-600"
onClick={async () => {
try {
const response = await fetch('/api/self', {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete account');
const response =
await fetch(
"/api/self",
{
method: "DELETE",
},
);
if (!response.ok)
throw new Error(
"Failed to delete account",
);
toast({
title: "Account Deleted",
description: "Your account has been successfully deleted.",
description:
"Your account has been successfully deleted.",
});
// Redirect to home page after successful deletion
window.location.href = "/";
window.location.href =
"/";
} catch (error) {
console.error('Error deleting account:', error);
console.error(
"Error deleting account:",
error,
);
toast({
title: "Error",
description: "Failed to delete account. Please try again or contact support.",
variant: "destructive"
description:
"Failed to delete account. Please try again or contact support.",
variant:
"destructive",
});
}
}}

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import styles from "./sharedChat.module.css";
import React, { Suspense, useEffect, useRef, useState } from "react";
@@ -19,6 +20,7 @@ import {
import { StreamMessage } from "@/app/components/chatMessage/chatMessage";
import { AgentData } from "@/app/components/agentCard/agentCard";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { DeprecationBanner } from "@/app/components/deprecationBanner";
import { AppSidebar } from "@/app/components/appSidebar/appSidebar";
import { Separator } from "@/components/ui/separator";
import { KhojLogoType } from "@/app/components/logo/khojLogo";
@@ -232,6 +234,7 @@ export default function SharedChat() {
<SidebarProvider>
<AppSidebar conversationId={conversationId || ""} />
<SidebarInset>
<DeprecationBanner />
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -240,9 +243,9 @@ export default function SharedChat() {
className={`${styles.chatTitleWrapper} text-nowrap text-ellipsis overflow-hidden max-w-screen-md grid items-top font-bold mx-2 md:mr-8 col-auto h-fit`}
>
{isMobileWidth ? (
<a className="p-0 no-underline" href="/">
<Link className="p-0 no-underline" href="/">
<KhojLogoType className="h-auto w-16" />
</a>
</Link>
) : (
title && (
<>

1617
src/interface/web/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,30 @@
"use client"
"use client";
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-secondary-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-gray-500 data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-secondary-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-gray-500 data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox }
export { Checkbox };

View File

@@ -1,29 +1,29 @@
"use client"
"use client";
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent }
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -1,118 +1,99 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
);
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
React.ComponentProps<"a">;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
"no-underline",
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
"no-underline",
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-gray-300 dark:data-[state=unchecked]:bg-gray-600",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -1,30 +1,30 @@
"use client"
"use client";
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,6 +1,6 @@
{
"name": "khoj-ai",
"version": "1.42.8",
"version": "2.0.0-beta.28",
"private": true,
"scripts": {
"dev": "next dev",
@@ -8,101 +8,102 @@
"start": "next start",
"lint": "next lint",
"collectstatic": "bash -c 'pushd ../../../ && source .venv/bin/activate && python3 src/khoj/manage.py collectstatic --noinput && deactivate && popd'",
"cicollectstatic": "bash -c 'pushd ../../../ && python3 src/khoj/manage.py collectstatic --noinput && popd'",
"export": "yarn build && cp -r out/ ../../khoj/interface/built && yarn collectstatic",
"ciexport": "yarn build && cp -r out/ ../../khoj/interface/built && yarn cicollectstatic",
"pypiciexport": "yarn build && cp -r out/ /opt/hostedtoolcache/Python/3.11.12/x64/lib/python3.11/site-packages/khoj/interface/compiled && yarn cicollectstatic",
"watch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'yarn export'",
"windowswatch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'yarn windowsexport'",
"cicollectstatic": "bash -c 'pushd ../../../ && uv run python src/khoj/manage.py collectstatic --noinput && popd'",
"export": "bun run build && cp -r out/ ../../khoj/interface/built && bun run collectstatic",
"ciexport": "bun run build && cp -r out/ ../../khoj/interface/built && bun run cicollectstatic",
"pypiciexport": "bun run build && mkdir -p ../../khoj/interface/compiled && cp -r out/. ../../khoj/interface/compiled && bun run cicollectstatic",
"watch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'bun run export'",
"windowswatch": "nodemon --watch . --ext js,jsx,ts,tsx,css --ignore 'out/**/*' --exec 'bun run windowsexport'",
"windowscollectstatic": "cd ..\\..\\.. && .\\.venv\\Scripts\\Activate.bat && py .\\src\\khoj\\manage.py collectstatic --noinput && .\\.venv\\Scripts\\deactivate.bat && cd ..",
"windowsexport": "yarn build && xcopy out ..\\..\\khoj\\interface\\built /E /Y && yarn windowscollectstatic",
"windowsexport": "bun run build && xcopy out ..\\..\\khoj\\interface\\built /E /Y && bun run windowscollectstatic",
"prepare": "husky"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.17.6",
"@hookform/resolvers": "^3.9.0",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.6",
"@radix-ui/themes": "^3.1.1",
"@types/file-saver": "^2.0.7",
"autoprefixer": "^10.4.19",
"@hookform/resolvers": "^3.10.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/themes": "^3.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cronstrue": "^2.50.0",
"dompurify": "^3.1.6",
"embla-carousel-autoplay": "^8.5.1",
"embla-carousel-react": "^8.5.1",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"cmdk": "^1.1.1",
"cronstrue": "^2.61.0",
"dompurify": "^3.3.2",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"file-saver": "^2.0.5",
"framer-motion": "^12.0.6",
"input-otp": "^1.2.4",
"intl-tel-input": "^23.8.1",
"framer-motion": "^12.23.12",
"input-otp": "^1.4.2",
"intl-tel-input": "^23.9.3",
"jszip": "^3.10.1",
"katex": "^0.16.21",
"libphonenumber-js": "^1.11.4",
"katex": "^0.16.22",
"libphonenumber-js": "^1.12.17",
"lucide-react": "^0.468.0",
"markdown-it": "^14.1.0",
"markdown-it-highlightjs": "^4.1.0",
"mermaid": "^11.4.1",
"next": "14.2.30",
"nodemon": "^3.1.3",
"postcss": "^8.4.38",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.52.1",
"shadcn-ui": "^0.9.0",
"swr": "^2.2.5",
"tailwind-merge": "^2.3.0",
"tailwindcss": "^3.4.6",
"markdown-it-highlightjs": "^4.2.0",
"mermaid": "^11.11.0",
"next": "15.5.14",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.62.0",
"react-use-websocket": "^4.13.0",
"swr": "^2.3.6",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5",
"vaul": "^0.9.1",
"zod": "^3.23.8"
"vaul": "^0.9.9",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/dompurify": "^3.2.0",
"@types/file-saver": "^2.0.7",
"@types/intl-tel-input": "^18.1.4",
"@types/katex": "^0.16.7",
"@types/markdown-it": "^14.1.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.11",
"lint-staged": "^15.2.7",
"nodemon": "^3.1.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.19.15",
"@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.1",
"eslint-config-next": "15.5.14",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-prettier": "^5.5.4",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"nodemon": "^3.1.10",
"postcss": "^8.5.6",
"prettier": "3.3.3",
"typescript": "^5"
"shadcn-ui": "^0.9.5",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2"
},
"prettier": {
"tabWidth": 4,
"printWidth": 100
},
"lint-staged": {
"*": "yarn lint --fix"
"*": "bun run lint"
},
"overrides": {
"picomatch": ">=2.3.2"
}
}

View File

@@ -26,7 +26,8 @@
"@/*": [
"./*"
]
}
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,7 @@ sudo -u postgres createdb khoj
### Install Khoj
```bash
pip install -e '.[dev]'
uv sync --all-extras
```
### Make Khoj DB migrations

View File

@@ -14,6 +14,7 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import path

View File

@@ -11,9 +11,15 @@ import requests
import schedule
from asgiref.sync import sync_to_async
from django.conf import settings
from django.db import close_old_connections, connections
from django.db import (
DatabaseError,
OperationalError,
close_old_connections,
connections,
)
from django.utils.timezone import make_aware
from fastapi import Request, Response
from fastapi import HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from starlette.authentication import (
AuthCredentials,
AuthenticationBackend,
@@ -44,13 +50,11 @@ from khoj.database.adapters import (
)
from khoj.database.models import ClientApplication, KhojUser, ProcessLock, Subscription
from khoj.processor.embeddings import CrossEncoderModel, EmbeddingsModel
from khoj.routers.api_content import configure_content, configure_search
from khoj.routers.api_content import configure_content
from khoj.routers.twilio import is_twilio_enabled
from khoj.utils import constants, state
from khoj.utils.config import SearchType
from khoj.utils.fs_syncer import collect_files
from khoj.utils.helpers import is_none_or_empty, telemetry_disabled
from khoj.utils.rawconfig import FullConfig
from khoj.utils.helpers import is_none_or_empty
logger = logging.getLogger(__name__)
@@ -113,13 +117,24 @@ class UserAuthenticationBackend(AuthenticationBackend):
Subscription.objects.create(user=default_user, type=Subscription.Type.STANDARD, renewal_date=renewal_date)
async def authenticate(self, request: HTTPConnection):
# Skip authentication for error pages to avoid infinite recursion
if request.url.path == "/server/error":
return AuthCredentials(), UnauthenticatedUser()
current_user = request.session.get("user")
if current_user and current_user.get("email"):
user = (
await self.khojuser_manager.filter(email=current_user.get("email"))
.prefetch_related("subscription")
.afirst()
)
try:
user = (
await self.khojuser_manager.filter(email=current_user.get("email"))
.prefetch_related("subscription")
.afirst()
)
except (DatabaseError, OperationalError):
logger.error("DB Exception: Failed to authenticate user", exc_info=True)
raise HTTPException(
status_code=503,
detail="Please report this issue on Github, Discord or email team@khoj.dev and try again later.",
)
if user:
subscribed = await ais_user_subscribed(user)
if subscribed:
@@ -131,12 +146,19 @@ class UserAuthenticationBackend(AuthenticationBackend):
# Get bearer token from header
bearer_token = request.headers["Authorization"].split("Bearer ")[1]
# Get user owning token
user_with_token = (
await self.khojapiuser_manager.filter(token=bearer_token)
.select_related("user")
.prefetch_related("user__subscription")
.afirst()
)
try:
user_with_token = (
await self.khojapiuser_manager.filter(token=bearer_token)
.select_related("user")
.prefetch_related("user__subscription")
.afirst()
)
except (DatabaseError, OperationalError):
logger.error("DB Exception: Failed to authenticate user applications", exc_info=True)
raise HTTPException(
status_code=503,
detail="Please report this issue on Github, Discord or email team@khoj.dev and try again later.",
)
if user_with_token:
subscribed = await ais_user_subscribed(user_with_token.user)
if subscribed:
@@ -155,7 +177,16 @@ class UserAuthenticationBackend(AuthenticationBackend):
)
# Get the client application
client_application = await ClientApplicationAdapters.aget_client_application_by_id(client_id, client_secret)
try:
client_application = await ClientApplicationAdapters.aget_client_application_by_id(
client_id, client_secret
)
except (DatabaseError, OperationalError):
logger.error("DB Exception: Failed to authenticate first party application", exc_info=True)
raise HTTPException(
status_code=503,
detail="Please report this issue on Github, Discord or email team@khoj.dev and try again later.",
)
if client_application is None:
return AuthCredentials(), UnauthenticatedUser()
# Get the identifier used for the user
@@ -185,21 +216,20 @@ class UserAuthenticationBackend(AuthenticationBackend):
# No auth required if server in anonymous mode
if state.anonymous_mode:
user = await self.khojuser_manager.filter(username="default").prefetch_related("subscription").afirst()
try:
user = await self.khojuser_manager.filter(username="default").prefetch_related("subscription").afirst()
except (DatabaseError, OperationalError):
logger.error("DB Exception: Failed to fetch default user from DB", exc_info=True)
raise HTTPException(
status_code=503,
detail="Please report this issue on Github, Discord or email team@khoj.dev and try again later.",
)
if user:
return AuthCredentials(["authenticated", "premium"]), AuthenticatedKhojUser(user)
return AuthCredentials(), UnauthenticatedUser()
def initialize_server(config: Optional[FullConfig]):
try:
configure_server(config, init=True)
except Exception as e:
logger.error(f"🚨 Failed to configure server on app load: {e}", exc_info=True)
raise e
def clean_connections(func):
"""
A decorator that ensures that Django database connections that have become unusable, or are obsolete, are closed
@@ -220,19 +250,7 @@ def clean_connections(func):
return func_wrapper
def configure_server(
config: FullConfig,
regenerate: bool = False,
search_type: Optional[SearchType] = None,
init=False,
user: KhojUser = None,
):
# Update Config
if config == None:
logger.info(f"Initializing with default config.")
config = FullConfig()
state.config = config
def initialize_server():
if ConversationAdapters.has_valid_ai_model_api():
ai_model_api = ConversationAdapters.get_ai_model_api()
state.openai_client = openai.OpenAI(api_key=ai_model_api.api_key, base_url=ai_model_api.api_base_url)
@@ -269,43 +287,33 @@ def configure_server(
)
state.SearchType = configure_search_types()
state.search_models = configure_search(state.search_models, state.config.search_type)
setup_default_agent(user)
setup_default_agent()
message = (
"📡 Telemetry disabled"
if telemetry_disabled(state.config.app, state.telemetry_disabled)
else "📡 Telemetry enabled"
)
message = "📡 Telemetry disabled" if state.telemetry_disabled else "📡 Telemetry enabled"
logger.info(message)
if not init:
initialize_content(user, regenerate, search_type)
except Exception as e:
logger.error(f"Failed to load some search models: {e}", exc_info=True)
def setup_default_agent(user: KhojUser):
AgentAdapters.create_default_agent(user)
def setup_default_agent():
AgentAdapters.create_default_agent()
def initialize_content(user: KhojUser, regenerate: bool, search_type: Optional[SearchType] = None):
# Initialize Content from Config
if state.search_models:
try:
logger.info("📬 Updating content index...")
all_files = collect_files(user=user)
status = configure_content(
user,
all_files,
regenerate,
search_type,
)
if not status:
raise RuntimeError("Failed to update content index")
except Exception as e:
raise e
try:
logger.info("📬 Updating content index...")
status = configure_content(
user,
{},
regenerate,
search_type,
)
if not status:
raise RuntimeError("Failed to update content index")
except Exception as e:
raise e
def configure_routes(app):
@@ -315,6 +323,7 @@ def configure_routes(app):
from khoj.routers.api_automation import api_automation
from khoj.routers.api_chat import api_chat
from khoj.routers.api_content import api_content
from khoj.routers.api_memories import api_memories
from khoj.routers.api_model import api_model
from khoj.routers.notion import notion_router
from khoj.routers.web_client import web_client
@@ -324,6 +333,7 @@ def configure_routes(app):
app.include_router(api_agents, prefix="/api/agents")
app.include_router(api_automation, prefix="/api/automation")
app.include_router(api_model, prefix="/api/model")
app.include_router(api_memories, prefix="/api/memories")
app.include_router(api_content, prefix="/api/content")
app.include_router(notion_router, prefix="/api/notion")
app.include_router(web_client)
@@ -368,19 +378,37 @@ def configure_middleware(app, ssl_enabled: bool = False):
# and prevent further error logging.
return Response(status_code=499)
class ServerErrorMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
try:
return await call_next(request)
except HTTPException as e:
# Check if this is a server error (5xx) that we want to handle
if e.status_code >= 500 and e.status_code < 600:
# Check if this is a web route (not API route)
path = request.url.path
is_api_route = path.startswith("/api/") or path.startswith("/server/")
# Redirect web routes to error page, let API routes get the raw error
if not is_api_route:
return RedirectResponse(url="/server/error", status_code=302)
# Re-raise for API routes and non-5xx errors
raise e
if ssl_enabled:
app.add_middleware(HTTPSRedirectMiddleware)
app.add_middleware(SuppressClientDisconnectMiddleware)
app.add_middleware(AsyncCloseConnectionsMiddleware)
app.add_middleware(AuthenticationMiddleware, backend=UserAuthenticationBackend())
app.add_middleware(ServerErrorMiddleware) # Add after AuthenticationMiddleware to catch its exceptions
app.add_middleware(NextJsMiddleware)
app.add_middleware(SessionMiddleware, secret_key=os.environ.get("KHOJ_DJANGO_SECRET_KEY", "!secret"))
def update_content_index():
for user in get_all_users():
all_files = collect_files(user=user)
success = configure_content(user, all_files)
success = configure_content(user, {})
if not success:
raise RuntimeError("Failed to update content index")
logger.info("📪 Content index updated via Scheduler")
@@ -405,7 +433,7 @@ def configure_search_types():
@schedule.repeat(schedule.every(2).minutes)
@clean_connections
def upload_telemetry():
if telemetry_disabled(state.config.app, state.telemetry_disabled) or not state.telemetry:
if state.telemetry_disabled or not state.telemetry:
return
try:

Some files were not shown because too many files have changed in this diff Show More