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,