diff --git a/common/constants.py b/common/constants.py
index dd24b4ead..1c3404786 100644
--- a/common/constants.py
+++ b/common/constants.py
@@ -118,6 +118,7 @@ class FileSource(StrEnum):
SHAREPOINT = "sharepoint"
SLACK = "slack"
TEAMS = "teams"
+ MOODLE = "moodle"
class PipelineTaskType(StrEnum):
diff --git a/common/data_source/__init__.py b/common/data_source/__init__.py
index 611c3c61a..e6480e65f 100644
--- a/common/data_source/__init__.py
+++ b/common/data_source/__init__.py
@@ -14,6 +14,7 @@ from .google_drive.connector import GoogleDriveConnector
from .jira.connector import JiraConnector
from .sharepoint_connector import SharePointConnector
from .teams_connector import TeamsConnector
+from .moodle_connector import MoodleConnector
from .config import BlobType, DocumentSource
from .models import Document, TextSection, ImageSection, BasicExpertInfo
from .exceptions import (
@@ -36,6 +37,7 @@ __all__ = [
"JiraConnector",
"SharePointConnector",
"TeamsConnector",
+ "MoodleConnector",
"BlobType",
"DocumentSource",
"Document",
diff --git a/common/data_source/config.py b/common/data_source/config.py
index 6cd497527..0c038c6d7 100644
--- a/common/data_source/config.py
+++ b/common/data_source/config.py
@@ -48,6 +48,7 @@ class DocumentSource(str, Enum):
GOOGLE_DRIVE = "google_drive"
GMAIL = "gmail"
DISCORD = "discord"
+ MOODLE = "moodle"
S3_COMPATIBLE = "s3_compatible"
diff --git a/common/data_source/moodle_connector.py b/common/data_source/moodle_connector.py
new file mode 100644
index 000000000..427e7e86c
--- /dev/null
+++ b/common/data_source/moodle_connector.py
@@ -0,0 +1,378 @@
+from __future__ import annotations
+
+import logging
+import os
+from collections.abc import Generator
+from datetime import datetime, timezone
+from retry import retry
+from typing import Any, Optional
+
+from markdownify import markdownify as md
+from moodle import Moodle as MoodleClient, MoodleException
+
+from common.data_source.config import INDEX_BATCH_SIZE
+from common.data_source.exceptions import (
+ ConnectorMissingCredentialError,
+ CredentialExpiredError,
+ InsufficientPermissionsError,
+ ConnectorValidationError,
+)
+from common.data_source.interfaces import LoadConnector, PollConnector, SecondsSinceUnixEpoch
+from common.data_source.models import Document
+from common.data_source.utils import batch_generator, rl_requests
+
+logger = logging.getLogger(__name__)
+
+
+class MoodleConnector(LoadConnector, PollConnector):
+ """Moodle LMS connector for accessing course content"""
+
+ def __init__(self, moodle_url: str, batch_size: int = INDEX_BATCH_SIZE) -> None:
+ self.moodle_url = moodle_url.rstrip("/")
+ self.batch_size = batch_size
+ self.moodle_client: Optional[MoodleClient] = None
+
+ def _add_token_to_url(self, file_url: str) -> str:
+ """Append Moodle token to URL if missing"""
+ if not self.moodle_client:
+ return file_url
+ token = getattr(self.moodle_client, "token", "")
+ if "token=" in file_url.lower():
+ return file_url
+ delimiter = "&" if "?" in file_url else "?"
+ return f"{file_url}{delimiter}token={token}"
+
+ def _log_error(self, context: str, error: Exception, level: str = "warning") -> None:
+ """Simplified logging wrapper"""
+ msg = f"{context}: {error}"
+ if level == "error":
+ logger.error(msg)
+ else:
+ logger.warning(msg)
+
+ def _get_latest_timestamp(self, *timestamps: int) -> int:
+ """Return latest valid timestamp"""
+ return max((t for t in timestamps if t and t > 0), default=0)
+
+ def _yield_in_batches(
+ self, generator: Generator[Document, None, None]
+ ) -> Generator[list[Document], None, None]:
+ for batch in batch_generator(generator, self.batch_size):
+ yield batch
+
+ def load_credentials(self, credentials: dict[str, Any]) -> None:
+ token = credentials.get("moodle_token")
+ if not token:
+ raise ConnectorMissingCredentialError("Moodle API token is required")
+
+ try:
+ self.moodle_client = MoodleClient(
+ self.moodle_url + "/webservice/rest/server.php", token
+ )
+ self.moodle_client.core.webservice.get_site_info()
+ except MoodleException as e:
+ if "invalidtoken" in str(e).lower():
+ raise CredentialExpiredError("Moodle token is invalid or expired")
+ raise ConnectorMissingCredentialError(f"Failed to initialize Moodle client: {e}")
+
+ def validate_connector_settings(self) -> None:
+ if not self.moodle_client:
+ raise ConnectorMissingCredentialError("Moodle client not initialized")
+
+ try:
+ site_info = self.moodle_client.core.webservice.get_site_info()
+ if not site_info.sitename:
+ raise InsufficientPermissionsError("Invalid Moodle API response")
+ except MoodleException as e:
+ msg = str(e).lower()
+ if "invalidtoken" in msg:
+ raise CredentialExpiredError("Moodle token is invalid or expired")
+ if "accessexception" in msg:
+ raise InsufficientPermissionsError(
+ "Insufficient permissions. Ensure web services are enabled and permissions are correct."
+ )
+ raise ConnectorValidationError(f"Moodle validation error: {e}")
+ except Exception as e:
+ raise ConnectorValidationError(f"Unexpected validation error: {e}")
+
+ # -------------------------------------------------------------------------
+ # Data loading & polling
+ # -------------------------------------------------------------------------
+
+ def load_from_state(self) -> Generator[list[Document], None, None]:
+ if not self.moodle_client:
+ raise ConnectorMissingCredentialError("Moodle client not initialized")
+
+ logger.info("Starting full load from Moodle workspace")
+ courses = self._get_enrolled_courses()
+ if not courses:
+ logger.warning("No courses found to process")
+ return
+
+ yield from self._yield_in_batches(self._process_courses(courses))
+
+ def poll_source(
+ self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
+ ) -> Generator[list[Document], None, None]:
+ if not self.moodle_client:
+ raise ConnectorMissingCredentialError("Moodle client not initialized")
+
+ logger.info(
+ f"Polling Moodle updates between {datetime.fromtimestamp(start)} and {datetime.fromtimestamp(end)}"
+ )
+ courses = self._get_enrolled_courses()
+ if not courses:
+ logger.warning("No courses found to poll")
+ return
+
+ yield from self._yield_in_batches(self._get_updated_content(courses, start, end))
+
+ @retry(tries=3, delay=1, backoff=2)
+ def _get_enrolled_courses(self) -> list:
+ if not self.moodle_client:
+ raise ConnectorMissingCredentialError("Moodle client not initialized")
+
+ try:
+ return self.moodle_client.core.course.get_courses()
+ except MoodleException as e:
+ self._log_error("fetching courses", e, "error")
+ raise ConnectorValidationError(f"Failed to fetch courses: {e}")
+
+ @retry(tries=3, delay=1, backoff=2)
+ def _get_course_contents(self, course_id: int):
+ if not self.moodle_client:
+ raise ConnectorMissingCredentialError("Moodle client not initialized")
+
+ try:
+ return self.moodle_client.core.course.get_contents(courseid=course_id)
+ except MoodleException as e:
+ self._log_error(f"fetching course contents for {course_id}", e)
+ return []
+
+ def _process_courses(self, courses) -> Generator[Document, None, None]:
+ for course in courses:
+ try:
+ contents = self._get_course_contents(course.id)
+ for section in contents:
+ for module in section.modules:
+ doc = self._process_module(course, section, module)
+ if doc:
+ yield doc
+ except Exception as e:
+ self._log_error(f"processing course {course.fullname}", e)
+
+ def _get_updated_content(
+ self, courses, start: float, end: float
+ ) -> Generator[Document, None, None]:
+ for course in courses:
+ try:
+ contents = self._get_course_contents(course.id)
+ for section in contents:
+ for module in section.modules:
+ times = [
+ getattr(module, "timecreated", 0),
+ getattr(module, "timemodified", 0),
+ ]
+ if hasattr(module, "contents"):
+ times.extend(
+ getattr(c, "timemodified", 0)
+ for c in module.contents
+ if c and getattr(c, "timemodified", 0)
+ )
+ last_mod = self._get_latest_timestamp(*times)
+ if start < last_mod <= end:
+ doc = self._process_module(course, section, module)
+ if doc:
+ yield doc
+ except Exception as e:
+ self._log_error(f"polling course {course.fullname}", e)
+
+ def _process_module(
+ self, course, section, module
+ ) -> Optional[Document]:
+ try:
+ mtype = module.modname
+ if mtype in ["label", "url"]:
+ return None
+ if mtype == "resource":
+ return self._process_resource(course, section, module)
+ if mtype == "forum":
+ return self._process_forum(course, section, module)
+ if mtype == "page":
+ return self._process_page(course, section, module)
+ if mtype in ["assign", "quiz"]:
+ return self._process_activity(course, section, module)
+ if mtype == "book":
+ return self._process_book(course, section, module)
+ except Exception as e:
+ self._log_error(f"processing module {getattr(module, 'name', '?')}", e)
+ return None
+
+ def _process_resource(self, course, section, module) -> Optional[Document]:
+ if not getattr(module, "contents", None):
+ return None
+
+ file_info = module.contents[0]
+ if not getattr(file_info, "fileurl", None):
+ return None
+
+ file_name = os.path.basename(file_info.filename)
+ ts = self._get_latest_timestamp(
+ getattr(module, "timecreated", 0),
+ getattr(module, "timemodified", 0),
+ getattr(file_info, "timemodified", 0),
+ )
+
+ try:
+ resp = rl_requests.get(self._add_token_to_url(file_info.fileurl), timeout=60)
+ resp.raise_for_status()
+ blob = resp.content
+ ext = os.path.splitext(file_name)[1] or ".bin"
+ semantic_id = f"{course.fullname} / {section.name} / {file_name}"
+ return Document(
+ id=f"moodle_resource_{module.id}",
+ source="moodle",
+ semantic_identifier=semantic_id,
+ extension=ext,
+ blob=blob,
+ doc_updated_at=datetime.fromtimestamp(ts or 0, tz=timezone.utc),
+ size_bytes=len(blob),
+ )
+ except Exception as e:
+ self._log_error(f"downloading resource {file_name}", e, "error")
+ return None
+
+ def _process_forum(self, course, section, module) -> Optional[Document]:
+ if not self.moodle_client or not getattr(module, "instance", None):
+ return None
+
+ try:
+ result = self.moodle_client.mod.forum.get_forum_discussions(forumid=module.instance)
+ disc_list = getattr(result, "discussions", [])
+ if not disc_list:
+ return None
+
+ markdown = [f"# {module.name}\n"]
+ latest_ts = self._get_latest_timestamp(
+ getattr(module, "timecreated", 0),
+ getattr(module, "timemodified", 0),
+ )
+
+ for d in disc_list:
+ markdown.append(f"## {d.name}\n\n{md(d.message or '')}\n\n---\n")
+ latest_ts = max(latest_ts, getattr(d, "timemodified", 0))
+
+ blob = "\n".join(markdown).encode("utf-8")
+ semantic_id = f"{course.fullname} / {section.name} / {module.name}"
+ return Document(
+ id=f"moodle_forum_{module.id}",
+ source="moodle",
+ semantic_identifier=semantic_id,
+ extension=".md",
+ blob=blob,
+ doc_updated_at=datetime.fromtimestamp(latest_ts or 0, tz=timezone.utc),
+ size_bytes=len(blob),
+ )
+ except Exception as e:
+ self._log_error(f"processing forum {module.name}", e)
+ return None
+
+ def _process_page(self, course, section, module) -> Optional[Document]:
+ if not getattr(module, "contents", None):
+ return None
+
+ file_info = module.contents[0]
+ if not getattr(file_info, "fileurl", None):
+ return None
+
+ file_name = os.path.basename(file_info.filename)
+ ts = self._get_latest_timestamp(
+ getattr(module, "timecreated", 0),
+ getattr(module, "timemodified", 0),
+ getattr(file_info, "timemodified", 0),
+ )
+
+ try:
+ resp = rl_requests.get(self._add_token_to_url(file_info.fileurl), timeout=60)
+ resp.raise_for_status()
+ blob = resp.content
+ ext = os.path.splitext(file_name)[1] or ".html"
+ semantic_id = f"{course.fullname} / {section.name} / {module.name}"
+ return Document(
+ id=f"moodle_page_{module.id}",
+ source="moodle",
+ semantic_identifier=semantic_id,
+ extension=ext,
+ blob=blob,
+ doc_updated_at=datetime.fromtimestamp(ts or 0, tz=timezone.utc),
+ size_bytes=len(blob),
+ )
+ except Exception as e:
+ self._log_error(f"processing page {file_name}", e, "error")
+ return None
+
+ def _process_activity(self, course, section, module) -> Optional[Document]:
+ desc = getattr(module, "description", "")
+ if not desc:
+ return None
+
+ mtype, mname = module.modname, module.name
+ markdown = f"# {mname}\n\n**Type:** {mtype.capitalize()}\n\n{md(desc)}"
+ ts = self._get_latest_timestamp(
+ getattr(module, "timecreated", 0),
+ getattr(module, "timemodified", 0),
+ getattr(module, "added", 0),
+ )
+
+ semantic_id = f"{course.fullname} / {section.name} / {mname}"
+ blob = markdown.encode("utf-8")
+ return Document(
+ id=f"moodle_{mtype}_{module.id}",
+ source="moodle",
+ semantic_identifier=semantic_id,
+ extension=".md",
+ blob=blob,
+ doc_updated_at=datetime.fromtimestamp(ts or 0, tz=timezone.utc),
+ size_bytes=len(blob),
+ )
+
+ def _process_book(self, course, section, module) -> Optional[Document]:
+ if not getattr(module, "contents", None):
+ return None
+
+ contents = module.contents
+ chapters = [
+ c for c in contents
+ if getattr(c, "fileurl", None) and os.path.basename(c.filename) == "index.html"
+ ]
+ if not chapters:
+ return None
+
+ latest_ts = self._get_latest_timestamp(
+ getattr(module, "timecreated", 0),
+ getattr(module, "timemodified", 0),
+ *[getattr(c, "timecreated", 0) for c in contents],
+ *[getattr(c, "timemodified", 0) for c in contents],
+ )
+
+ markdown_parts = [f"# {module.name}\n"]
+ for ch in chapters:
+ try:
+ resp = rl_requests.get(self._add_token_to_url(ch.fileurl), timeout=60)
+ resp.raise_for_status()
+ html = resp.content.decode("utf-8", errors="ignore")
+ markdown_parts.append(md(html) + "\n\n---\n")
+ except Exception as e:
+ self._log_error(f"processing book chapter {ch.filename}", e)
+
+ blob = "\n".join(markdown_parts).encode("utf-8")
+ semantic_id = f"{course.fullname} / {section.name} / {module.name}"
+ return Document(
+ id=f"moodle_book_{module.id}",
+ source="moodle",
+ semantic_identifier=semantic_id,
+ extension=".md",
+ blob=blob,
+ doc_updated_at=datetime.fromtimestamp(latest_ts or 0, tz=timezone.utc),
+ size_bytes=len(blob),
+ )
diff --git a/common/settings.py b/common/settings.py
index ac2b13e00..9df0c0cd2 100644
--- a/common/settings.py
+++ b/common/settings.py
@@ -139,7 +139,7 @@ def _get_or_create_secret_key():
import logging
new_key = secrets.token_hex(32)
- logging.warning(f"SECURITY WARNING: Using auto-generated SECRET_KEY. Generated key: {new_key}")
+ logging.warning("SECURITY WARNING: Using auto-generated SECRET_KEY.")
return new_key
class StorageFactory:
diff --git a/docker/nginx/ragflow.https.conf b/docker/nginx/ragflow.https.conf
index 71cd241e7..2ef4cd005 100644
--- a/docker/nginx/ragflow.https.conf
+++ b/docker/nginx/ragflow.https.conf
@@ -23,12 +23,12 @@ server {
gzip_disable "MSIE [1-6]\.";
location ~ ^/api/v1/admin {
- proxy_pass http://ragflow:9381;
+ proxy_pass http://localhost:9381;
include proxy.conf;
}
location ~ ^/(v1|api) {
- proxy_pass http://ragflow:9380;
+ proxy_pass http://localhost:9380;
include proxy.conf;
}
diff --git a/pyproject.toml b/pyproject.toml
index f8902d00f..4b03b0eb3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,7 @@ dependencies = [
"arxiv==2.1.3",
"aspose-slides>=25.10.0,<26.0.0; platform_machine == 'x86_64' or (sys_platform == 'darwin' and platform_machine == 'arm64')",
"atlassian-python-api==4.0.7",
- "beartype>=0.18.5,<0.19.0",
+ "beartype>=0.20.0,<1.0.0",
"bio==1.7.1",
"blinker==1.7.0",
"boto3==1.34.140",
@@ -127,13 +127,13 @@ dependencies = [
"google-generativeai>=0.8.1,<0.9.0", # Needed for cv_model and embedding_model
"python-docx>=1.1.2,<2.0.0",
"pypdf2>=3.0.1,<4.0.0",
- "graspologic>=3.4.1,<4.0.0",
+ "graspologic @ git+https://github.com/yuzhichang/graspologic.git@38e680cab72bc9fb68a7992c3bcc2d53b24e42fd",
"mini-racer>=0.12.4,<0.13.0",
"pyodbc>=5.2.0,<6.0.0",
"pyicu>=2.15.3,<3.0.0",
"flasgger>=0.9.7.1,<0.10.0",
"xxhash>=3.5.0,<4.0.0",
- "trio>=0.29.0",
+ "trio>=0.17.0,<0.29.0",
"langfuse>=2.60.0",
"debugpy>=1.8.13",
"mcp>=1.9.4",
@@ -148,6 +148,7 @@ dependencies = [
"markdownify>=1.2.0",
"captcha>=0.7.1",
"pip>=25.2",
+ "moodlepy>=0.23.0",
"pypandoc>=1.16",
"pyobvector==0.2.18",
]
diff --git a/rag/llm/chat_model.py b/rag/llm/chat_model.py
index 856d23b01..cce5b2454 100644
--- a/rag/llm/chat_model.py
+++ b/rag/llm/chat_model.py
@@ -1635,6 +1635,15 @@ class LiteLLMBase(ABC):
provider_cfg["allow_fallbacks"] = False
extra_body["provider"] = provider_cfg
completion_args.update({"extra_body": extra_body})
+
+ # Ollama deployments commonly sit behind a reverse proxy that enforces
+ # Bearer auth. Ensure the Authorization header is set when an API key
+ # is provided, while respecting any user-supplied headers. #11350
+ extra_headers = deepcopy(completion_args.get("extra_headers") or {})
+ if self.provider == SupportedLiteLLMProvider.Ollama and self.api_key and "Authorization" not in extra_headers:
+ extra_headers["Authorization"] = f"Bearer {self.api_key}"
+ if extra_headers:
+ completion_args["extra_headers"] = extra_headers
return completion_args
def chat_with_tools(self, system: str, history: list, gen_conf: dict = {}):
diff --git a/rag/svr/sync_data_source.py b/rag/svr/sync_data_source.py
index 6925eb5f7..b29ad15de 100644
--- a/rag/svr/sync_data_source.py
+++ b/rag/svr/sync_data_source.py
@@ -37,14 +37,8 @@ from api.db.services.connector_service import ConnectorService, SyncLogsService
from api.db.services.knowledgebase_service import KnowledgebaseService
from common import settings
from common.config_utils import show_configs
+from common.data_source import BlobStorageConnector, NotionConnector, DiscordConnector, GoogleDriveConnector, MoodleConnector, JiraConnector
from common.constants import FileSource, TaskStatus
-from common.data_source import (
- BlobStorageConnector,
- DiscordConnector,
- GoogleDriveConnector,
- JiraConnector,
- NotionConnector,
-)
from common.data_source.config import INDEX_BATCH_SIZE
from common.data_source.confluence_connector import ConfluenceConnector
from common.data_source.interfaces import CheckpointOutputWrapper
@@ -418,6 +412,37 @@ class Teams(SyncBase):
pass
+class Moodle(SyncBase):
+ SOURCE_NAME: str = FileSource.MOODLE
+
+ async def _generate(self, task: dict):
+ self.connector = MoodleConnector(
+ moodle_url=self.conf["moodle_url"],
+ batch_size=self.conf.get("batch_size", INDEX_BATCH_SIZE)
+ )
+
+ self.connector.load_credentials(self.conf["credentials"])
+
+ # Determine the time range for synchronization based on reindex or poll_range_start
+ if task["reindex"] == "1" or not task.get("poll_range_start"):
+ document_generator = self.connector.load_from_state()
+ begin_info = "totally"
+ else:
+ poll_start = task["poll_range_start"]
+ if poll_start is None:
+ document_generator = self.connector.load_from_state()
+ begin_info = "totally"
+ else:
+ document_generator = self.connector.poll_source(
+ poll_start.timestamp(),
+ datetime.now(timezone.utc).timestamp()
+ )
+ begin_info = "from {}".format(poll_start)
+
+ logging.info("Connect to Moodle: {} {}".format(self.conf["moodle_url"], begin_info))
+ return document_generator
+
+
func_factory = {
FileSource.S3: S3,
FileSource.NOTION: Notion,
@@ -429,6 +454,7 @@ func_factory = {
FileSource.SHAREPOINT: SharePoint,
FileSource.SLACK: Slack,
FileSource.TEAMS: Teams,
+ FileSource.MOODLE: Moodle
}
diff --git a/uv.lock b/uv.lock
index 6a6728adc..4d5747b2e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -343,11 +343,11 @@ wheels = [
[[package]]
name = "attrs"
-version = "25.3.0"
+version = "22.2.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/31/3f468da74c7de4fcf9b25591e682856389b3400b4b62f201e65f15ea3e07/attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99", size = 215900, upload-time = "2022-12-21T09:48:51.773Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/6e/6f83bf616d2becdf333a1640f1d463fef3150e2e926b7010cb0f81c95e88/attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", size = 60018, upload-time = "2022-12-21T09:48:49.401Z" },
]
[[package]]
@@ -456,11 +456,11 @@ wheels = [
[[package]]
name = "beartype"
-version = "0.18.5"
+version = "0.22.6"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/96/15/4e623478a9628ad4cee2391f19aba0b16c1dd6fedcb2a399f0928097b597/beartype-0.18.5.tar.gz", hash = "sha256:264ddc2f1da9ec94ff639141fbe33d22e12a9f75aa863b83b7046ffff1381927", size = 1193506, upload-time = "2024-04-21T07:25:58.64Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/88/e2/105ceb1704cb80fe4ab3872529ab7b6f365cf7c74f725e6132d0efcf1560/beartype-0.22.6.tar.gz", hash = "sha256:97fbda69c20b48c5780ac2ca60ce3c1bb9af29b3a1a0216898ffabdd523e48f4", size = 1588975, upload-time = "2025-11-20T04:47:14.736Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/43/7a1259741bd989723272ac7d381a43be932422abcff09a1d9f7ba212cb74/beartype-0.18.5-py3-none-any.whl", hash = "sha256:5301a14f2a9a5540fe47ec6d34d758e9cd8331d36c4760fc7a5499ab86310089", size = 917762, upload-time = "2024-04-21T07:25:55.758Z" },
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/c9/ceecc71fe2c9495a1d8e08d44f5f31f5bca1350d5b2e27a4b6265424f59e/beartype-0.22.6-py3-none-any.whl", hash = "sha256:0584bc46a2ea2a871509679278cda992eadde676c01356ab0ac77421f3c9a093", size = 1324807, upload-time = "2025-11-20T04:47:11.837Z" },
]
[[package]]
@@ -668,6 +668,19 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/ff/3f0982ecd37c2d6a7266c22e7ea2e47d0773fe449984184c5316459d2776/captcha-0.7.1-py3-none-any.whl", hash = "sha256:8b73b5aba841ad1e5bdb856205bf5f09560b728ee890eb9dae42901219c8c599", size = 147606, upload-time = "2025-03-01T05:00:10.433Z" },
]
+[[package]]
+name = "cattrs"
+version = "22.2.0"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/da/ff3239eb4241cbc6f8b69f53d4ca27a178d51f9e5a954f1a3588c8227dc5/cattrs-22.2.0.tar.gz", hash = "sha256:f0eed5642399423cf656e7b66ce92cdc5b963ecafd041d1b24d136fdde7acf6d", size = 30050, upload-time = "2022-10-03T11:00:37.889Z" }
+wheels = [
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/3b/1d34fc4449174dfd2bc5ad7047a23edb6558b2e4b5a41b25a8ad6655c6c7/cattrs-22.2.0-py3-none-any.whl", hash = "sha256:bc12b1f0d000b9f9bee83335887d532a1d3e99a833d1bf0882151c97d3e68c21", size = 35673, upload-time = "2022-10-03T11:00:36.109Z" },
+]
+
[[package]]
name = "cbor"
version = "1.0.0"
@@ -2177,11 +2190,12 @@ wheels = [
[[package]]
name = "graspologic"
-version = "3.4.1"
-source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+version = "0.1.dev847+g38e680cab"
+source = { git = "https://github.com/yuzhichang/graspologic.git?rev=38e680cab72bc9fb68a7992c3bcc2d53b24e42fd#38e680cab72bc9fb68a7992c3bcc2d53b24e42fd" }
dependencies = [
{ name = "anytree" },
{ name = "beartype" },
+ { name = "future" },
{ name = "gensim" },
{ name = "graspologic-native" },
{ name = "hyppo" },
@@ -2198,10 +2212,6 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "umap-learn" },
]
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/de/83d653cc8029dc8c5f75bc5aea68f6b1e834230f05525fb3e7ac4aeae226/graspologic-3.4.1.tar.gz", hash = "sha256:7561f0b852a2bccd351bff77e8db07d9892f9dfa35a420fdec01690e4fdc8075", size = 5134018, upload-time = "2024-05-22T22:54:42.797Z" }
-wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/0b/9a167cec9cc4555b59cd282e8669998a50cb3f929a9a503965b24fa58a20/graspologic-3.4.1-py3-none-any.whl", hash = "sha256:c6563e087eda599bad1de831d4b7321c0daa7a82f4e85a7d7737ff67e07cdda2", size = 5200768, upload-time = "2024-05-22T22:54:39.259Z" },
-]
[[package]]
name = "graspologic-native"
@@ -2557,17 +2567,22 @@ wheels = [
[[package]]
name = "hyppo"
-version = "0.4.0"
+version = "0.5.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "autograd" },
+ { name = "future" },
{ name = "numba" },
{ name = "numpy" },
+ { name = "pandas" },
+ { name = "patsy" },
{ name = "scikit-learn" },
{ name = "scipy" },
+ { name = "statsmodels" },
]
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/a6/0d84fe8486a1447da8bdb8ebb249d525fd8c1d0fe038bceb003c6e0513f9/hyppo-0.5.2.tar.gz", hash = "sha256:4634d15516248a43d25c241ed18beeb79bb3210360f7253693b3f154fe8c9879", size = 125115, upload-time = "2025-05-24T18:33:27.418Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/87/7940713f929d0280cff1bde207479cb588a0d3a4dd49a0e2e69bfff46363/hyppo-0.4.0-py3-none-any.whl", hash = "sha256:4e75565b8deb601485cd7bc1b5c3f44e6ddf329136fc81e65d011f9b4e95132f", size = 146607, upload-time = "2023-05-24T13:50:04.441Z" },
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ea/c4/d46858cfac3c0aad314a1fc378beae5c8cac499b677650a34b5a6a3d4328/hyppo-0.5.2-py3-none-any.whl", hash = "sha256:5cc18f9e158fe2cf1804c9a1e979e807118ee89a303f29dc5cb8891d92d44ef3", size = 192272, upload-time = "2025-05-24T18:33:25.904Z" },
]
[[package]]
@@ -3383,6 +3398,20 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/4d/23c4e4f09da849e127e9f123241946c23c1e30f45a88366879e064211815/mistune-3.1.3-py3-none-any.whl", hash = "sha256:1a32314113cff28aa6432e99e522677c8587fd83e3d51c29b82a52409c842bd9", size = 53410, upload-time = "2025-03-19T14:27:23.451Z" },
]
+[[package]]
+name = "moodlepy"
+version = "0.24.1"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "cattrs" },
+ { name = "requests" },
+]
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/83/d03072735fc822b225efefa7f7646a4ed6cd73d1c717a338871b9958ce5d/moodlepy-0.24.1.tar.gz", hash = "sha256:94d361e4da56748d29910e01979e4652a42220994112b4f07589f200cb7915e3", size = 82174, upload-time = "2024-10-11T11:53:45.433Z" }
+wheels = [
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/6b/c526e3230e20171d7791f0fb2137aa4b49c82d9878959f089be1162a7c72/moodlepy-0.24.1-py3-none-any.whl", hash = "sha256:2809ece7a167d7ecc2a744cde188f0af66e1db58863e7a2ed77d1a0b08ff82e2", size = 153323, upload-time = "2024-10-11T11:53:44.149Z" },
+]
+
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -5375,6 +5404,7 @@ dependencies = [
{ name = "mini-racer" },
{ name = "minio" },
{ name = "mistralai" },
+ { name = "moodlepy" },
{ name = "mypy-boto3-s3" },
{ name = "nltk" },
{ name = "numpy" },
@@ -5477,7 +5507,7 @@ requires-dist = [
{ name = "azure-identity", specifier = "==1.17.1" },
{ name = "azure-storage-blob", specifier = "==12.22.0" },
{ name = "azure-storage-file-datalake", specifier = "==12.16.0" },
- { name = "beartype", specifier = ">=0.18.5,<0.19.0" },
+ { name = "beartype", specifier = ">=0.20.0,<1.0.0" },
{ name = "bio", specifier = "==1.7.1" },
{ name = "blinker", specifier = "==1.7.0" },
{ name = "boto3", specifier = "==1.34.140" },
@@ -5513,7 +5543,7 @@ requires-dist = [
{ name = "google-genai", specifier = ">=1.41.0,<2.0.0" },
{ name = "google-generativeai", specifier = ">=0.8.1,<0.9.0" },
{ name = "google-search-results", specifier = "==2.4.2" },
- { name = "graspologic", specifier = ">=3.4.1,<4.0.0" },
+ { name = "graspologic", git = "https://github.com/yuzhichang/graspologic.git?rev=38e680cab72bc9fb68a7992c3bcc2d53b24e42fd" },
{ name = "groq", specifier = "==0.9.0" },
{ name = "hanziconv", specifier = "==0.3.2" },
{ name = "html-text", specifier = "==0.6.2" },
@@ -5535,6 +5565,7 @@ requires-dist = [
{ name = "mini-racer", specifier = ">=0.12.4,<0.13.0" },
{ name = "minio", specifier = "==7.2.4" },
{ name = "mistralai", specifier = "==0.4.2" },
+ { name = "moodlepy", specifier = ">=0.23.0" },
{ name = "mypy-boto3-s3", specifier = "==1.40.26" },
{ name = "nltk", specifier = "==3.9.1" },
{ name = "numpy", specifier = ">=1.26.0,<2.0.0" },
@@ -5597,7 +5628,7 @@ requires-dist = [
{ name = "tencentcloud-sdk-python", specifier = "==3.0.1478" },
{ name = "tika", specifier = "==2.6.0" },
{ name = "tiktoken", specifier = "==0.7.0" },
- { name = "trio", specifier = ">=0.29.0" },
+ { name = "trio", specifier = ">=0.17.0,<0.29.0" },
{ name = "umap-learn", specifier = "==0.5.6" },
{ name = "valkey", specifier = "==6.0.2" },
{ name = "vertexai", specifier = "==1.70.0" },
@@ -6880,7 +6911,7 @@ wheels = [
[[package]]
name = "trio"
-version = "0.30.0"
+version = "0.24.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "attrs" },
@@ -6891,9 +6922,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "sortedcontainers" },
]
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/c1/68d582b4d3a1c1f8118e18042464bb12a7c1b75d64d75111b297687041e3/trio-0.30.0.tar.gz", hash = "sha256:0781c857c0c81f8f51e0089929a26b5bb63d57f927728a5586f7e36171f064df", size = 593776, upload-time = "2025-04-21T00:48:19.507Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/f3/07c152213222c615fe2391b8e1fea0f5af83599219050a549c20fcbd9ba2/trio-0.24.0.tar.gz", hash = "sha256:ffa09a74a6bf81b84f8613909fb0beaee84757450183a7a2e0b47b455c0cac5d", size = 545131, upload-time = "2024-01-10T03:29:21.671Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/8e/3f6dfda475ecd940e786defe6df6c500734e686c9cd0a0f8ef6821e9b2f2/trio-0.30.0-py3-none-any.whl", hash = "sha256:3bf4f06b8decf8d3cf00af85f40a89824669e2d033bb32469d34840edcfc22a5", size = 499194, upload-time = "2025-04-21T00:48:17.167Z" },
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/fb/9299cf74953f473a15accfdbe2c15218e766bae8c796f2567c83bae03e98/trio-0.24.0-py3-none-any.whl", hash = "sha256:c3bd3a4e3e3025cd9a2241eae75637c43fe0b9e88b4c97b9161a55b9e54cd72c", size = 460205, upload-time = "2024-01-10T03:29:20.165Z" },
]
[[package]]
diff --git a/web/src/assets/svg/data-source/moodle.svg b/web/src/assets/svg/data-source/moodle.svg
new file mode 100644
index 000000000..d268572cd
--- /dev/null
+++ b/web/src/assets/svg/data-source/moodle.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts
index 122560c0d..44eff8144 100644
--- a/web/src/locales/en.ts
+++ b/web/src/locales/en.ts
@@ -739,9 +739,15 @@ Example: https://fsn1.your-objectstorage.com`,
google_drivePrimaryAdminTip:
'Email address that has access to the Drive content being synced.',
google_driveMyDriveEmailsTip:
- 'Comma-separated emails whose “My Drive” contents should be indexed (include the primary admin).',
+ 'Comma-separated emails whose "My Drive" contents should be indexed (include the primary admin).',
google_driveSharedFoldersTip:
'Comma-separated Google Drive folder links to crawl.',
+ moodleDescription:
+ 'Connect to your Moodle LMS to sync course content, forums, and resources.',
+ moodleUrlTip:
+ 'The base URL of your Moodle instance (e.g., https://moodle.university.edu). Do not include /webservice or /login.',
+ moodleTokenTip:
+ 'Generate a web service token in Moodle: Go to Site administration → Server → Web services → Manage tokens. The user must be enrolled in the courses you want to sync.',
jiraDescription:
'Connect your Jira workspace to sync issues, comments, and attachments.',
jiraBaseUrlTip:
diff --git a/web/src/pages/next-chats/chat/conversation-dropdown.tsx b/web/src/pages/next-chats/chat/conversation-dropdown.tsx
index 83193f57c..27bed0a7e 100644
--- a/web/src/pages/next-chats/chat/conversation-dropdown.tsx
+++ b/web/src/pages/next-chats/chat/conversation-dropdown.tsx
@@ -14,16 +14,28 @@ import { useTranslation } from 'react-i18next';
export function ConversationDropdown({
children,
conversation,
+ removeTemporaryConversation,
}: PropsWithChildren & {
conversation: IConversation;
+ removeTemporaryConversation?: (conversationId: string) => void;
}) {
const { t } = useTranslation();
const { removeConversation } = useRemoveConversation();
const handleDelete: MouseEventHandler = useCallback(() => {
- removeConversation([conversation.id]);
- }, [conversation.id, removeConversation]);
+ if (conversation.is_new && removeTemporaryConversation) {
+ removeTemporaryConversation(conversation.id);
+ removeConversation([]);
+ } else {
+ removeConversation([conversation.id]);
+ }
+ }, [
+ conversation.id,
+ conversation.is_new,
+ removeConversation,
+ removeTemporaryConversation,
+ ]);
return (
diff --git a/web/src/pages/next-chats/chat/sessions.tsx b/web/src/pages/next-chats/chat/sessions.tsx
index 98b707374..15433b38d 100644
--- a/web/src/pages/next-chats/chat/sessions.tsx
+++ b/web/src/pages/next-chats/chat/sessions.tsx
@@ -29,6 +29,7 @@ export function Sessions({
const {
list: conversationList,
addTemporaryConversation,
+ removeTemporaryConversation,
handleInputChange,
searchString,
} = useSelectDerivedConversationList();
@@ -97,7 +98,10 @@ export function Sessions({
>
{x.name}
-
+
diff --git a/web/src/pages/next-chats/hooks/use-select-conversation-list.ts b/web/src/pages/next-chats/hooks/use-select-conversation-list.ts
index a40c57a0c..a37d3078b 100644
--- a/web/src/pages/next-chats/hooks/use-select-conversation-list.ts
+++ b/web/src/pages/next-chats/hooks/use-select-conversation-list.ts
@@ -80,6 +80,14 @@ export const useSelectDerivedConversationList = () => {
});
}, [conversationList, dialogId, prologue, t, setNewConversationRouteParams]);
+ const removeTemporaryConversation = useCallback((conversationId: string) => {
+ setList((prevList) => {
+ return prevList.filter(
+ (conversation) => conversation.id !== conversationId,
+ );
+ });
+ }, []);
+
// When you first enter the page, select the top conversation card
useEffect(() => {
@@ -89,6 +97,7 @@ export const useSelectDerivedConversationList = () => {
return {
list,
addTemporaryConversation,
+ removeTemporaryConversation,
loading,
handleInputChange,
searchString,
diff --git a/web/src/pages/user-setting/data-source/contant.tsx b/web/src/pages/user-setting/data-source/contant.tsx
index a1f0241fa..cc45ad869 100644
--- a/web/src/pages/user-setting/data-source/contant.tsx
+++ b/web/src/pages/user-setting/data-source/contant.tsx
@@ -9,7 +9,8 @@ export enum DataSourceKey {
NOTION = 'notion',
DISCORD = 'discord',
GOOGLE_DRIVE = 'google_drive',
- // GMAIL = 'gmail',
+ MOODLE = 'moodle',
+ // GMAIL = 'gmail',
JIRA = 'jira',
// SHAREPOINT = 'sharepoint',
// SLACK = 'slack',
@@ -42,6 +43,11 @@ export const DataSourceInfo = {
description: t(`setting.${DataSourceKey.GOOGLE_DRIVE}Description`),
icon: ,
},
+ [DataSourceKey.MOODLE]: {
+ name: 'Moodle',
+ description: t(`setting.${DataSourceKey.MOODLE}Description`),
+ icon: ,
+ },
[DataSourceKey.JIRA]: {
name: 'Jira',
description: t(`setting.${DataSourceKey.JIRA}Description`),
@@ -116,7 +122,7 @@ export const DataSourceFormFields = {
required: false,
placeholder: 'https://fsn1.your-objectstorage.com',
tooltip: t('setting.S3CompatibleEndpointUrlTip'),
- shouldRender: (formValues) => {
+ shouldRender: (formValues: any) => {
return formValues?.config?.bucket_type === 's3_compatible';
},
},
@@ -287,6 +293,21 @@ export const DataSourceFormFields = {
defaultValue: 'uploaded',
},
],
+ [DataSourceKey.MOODLE]: [
+ {
+ label: 'Moodle URL',
+ name: 'config.moodle_url',
+ type: FormFieldType.Text,
+ required: true,
+ placeholder: 'https://moodle.example.com',
+ },
+ {
+ label: 'API Token',
+ name: 'config.credentials.moodle_token',
+ type: FormFieldType.Password,
+ required: true,
+ },
+ ],
[DataSourceKey.JIRA]: [
{
label: 'Jira Base URL',
@@ -456,6 +477,16 @@ export const DataSourceFormDefaultValues = {
},
},
},
+ [DataSourceKey.MOODLE]: {
+ name: '',
+ source: DataSourceKey.MOODLE,
+ config: {
+ moodle_url: '',
+ credentials: {
+ moodle_token: '',
+ },
+ },
+ },
[DataSourceKey.JIRA]: {
name: '',
source: DataSourceKey.JIRA,
diff --git a/web/src/pages/user-setting/data-source/index.tsx b/web/src/pages/user-setting/data-source/index.tsx
index 3f4bef73c..2ba7cecd0 100644
--- a/web/src/pages/user-setting/data-source/index.tsx
+++ b/web/src/pages/user-setting/data-source/index.tsx
@@ -44,6 +44,12 @@ const dataSourceTemplates = [
description: DataSourceInfo[DataSourceKey.NOTION].description,
icon: DataSourceInfo[DataSourceKey.NOTION].icon,
},
+ {
+ id: DataSourceKey.MOODLE,
+ name: DataSourceInfo[DataSourceKey.MOODLE].name,
+ description: DataSourceInfo[DataSourceKey.MOODLE].description,
+ icon: DataSourceInfo[DataSourceKey.MOODLE].icon,
+ },
{
id: DataSourceKey.JIRA,
name: DataSourceInfo[DataSourceKey.JIRA].name,