diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py index da523421..c34cc22a 100644 --- a/backend/app/controller/tool_controller.py +++ b/backend/app/controller/tool_controller.py @@ -12,18 +12,35 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +from typing import Optional from fastapi import APIRouter, HTTPException +from pydantic import BaseModel from app.utils.toolkit.notion_mcp_toolkit import NotionMCPToolkit from app.utils.toolkit.google_calendar_toolkit import GoogleCalendarToolkit +from app.utils.toolkit.linkedin_toolkit import LinkedInToolkit from app.utils.oauth_state_manager import oauth_state_manager import logging + + from camel.toolkits.hybrid_browser_toolkit.hybrid_browser_toolkit_ts import ( HybridBrowserToolkit as BaseHybridBrowserToolkit, ) from app.utils.cookie_manager import CookieManager + + import os +import time import uuid + +class LinkedInTokenRequest(BaseModel): + r"""Request model for saving LinkedIn OAuth token.""" + access_token: str + refresh_token: Optional[str] = None + expires_in: Optional[int] = None + scope: Optional[str] = None + + logger = logging.getLogger("tool_controller") router = APIRouter() @@ -118,10 +135,81 @@ async def install_tool(tool: str): status_code=500, detail=f"Failed to install {tool}: {str(e)}" ) + elif tool == "linkedin": + try: + # Check if LinkedIn is already authenticated + if LinkedInToolkit.is_authenticated(): + # Check if token is expired + if LinkedInToolkit.is_token_expired(): + logger.info("LinkedIn token has expired") + return { + "success": False, + "status": "token_expired", + "message": "LinkedIn token has expired. Please re-authenticate via OAuth.", + "toolkit_name": "LinkedInToolkit", + "requires_auth": True, + "oauth_url": "/api/oauth/linkedin/login" + } + + try: + toolkit = LinkedInToolkit("install_auth") + tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()] + + # Try to get profile to verify token is valid + profile = toolkit.get_profile_safe() + + # Check if token is expiring soon + token_warning = None + if LinkedInToolkit.is_token_expiring_soon(): + token_info = LinkedInToolkit.get_token_info() + if token_info and token_info.get("expires_at"): + days_remaining = (token_info["expires_at"] - int(time.time())) // (24 * 60 * 60) + token_warning = f"Token expires in {days_remaining} days. Consider re-authenticating soon." + + logger.info(f"Successfully initialized LinkedIn toolkit with {len(tools)} tools") + result = { + "success": True, + "tools": tools, + "message": f"Successfully installed {tool} toolkit", + "count": len(tools), + "toolkit_name": "LinkedInToolkit", + "profile": profile if "error" not in profile else None + } + if token_warning: + result["warning"] = token_warning + return result + except Exception as e: + logger.warning(f"LinkedIn token may be invalid: {e}") + # Token exists but may be expired/invalid + return { + "success": False, + "status": "token_invalid", + "message": "LinkedIn token may be expired or invalid. Please re-authenticate via OAuth.", + "toolkit_name": "LinkedInToolkit", + "requires_auth": True, + "oauth_url": "/api/oauth/linkedin/login" + } + else: + # No credentials - need OAuth authorization + logger.info("No LinkedIn credentials found, OAuth required") + return { + "success": False, + "status": "not_configured", + "message": "LinkedIn OAuth required. Redirect user to OAuth login.", + "toolkit_name": "LinkedInToolkit", + "requires_auth": True, + "oauth_url": "/api/oauth/linkedin/login" + } + except Exception as e: + logger.error(f"Failed to install {tool} toolkit: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to install {tool}: {str(e)}" + ) else: raise HTTPException( status_code=404, - detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar']" + detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar', 'linkedin']" ) @@ -148,6 +236,14 @@ async def list_available_tools(): "description": "Google Calendar integration for managing events and schedules", "toolkit_class": "GoogleCalendarToolkit", "requires_auth": True + }, + { + "name": "linkedin", + "display_name": "LinkedIn", + "description": "LinkedIn integration for creating posts, managing profile, and social media automation", + "toolkit_class": "LinkedInToolkit", + "requires_auth": True, + "oauth_url": "/api/oauth/linkedin/login" } ] } @@ -309,10 +405,140 @@ async def uninstall_tool(tool: str): status_code=500, detail=f"Failed to uninstall {tool}: {str(e)}" ) + elif tool == "linkedin": + try: + # Clear LinkedIn token + success = LinkedInToolkit.clear_token() + + if success: + return { + "success": True, + "message": f"Successfully uninstalled {tool} and cleaned up authentication tokens" + } + else: + return { + "success": True, + "message": f"Uninstalled {tool} (no tokens found to clean up)" + } + except Exception as e: + logger.error(f"Failed to uninstall {tool}: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to uninstall {tool}: {str(e)}" + ) else: raise HTTPException( status_code=404, - detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar']" + detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar', 'linkedin']" + ) + + +@router.post("/linkedin/save-token", name="save linkedin token") +async def save_linkedin_token(token_request: LinkedInTokenRequest): + r"""Save LinkedIn OAuth token after successful authorization. + + Args: + token_request: Token data containing access_token and optionally refresh_token + + Returns: + Save result with tool information + """ + try: + token_data = token_request.model_dump(exclude_none=True) + + # Save the token + success = LinkedInToolkit.save_token(token_data) + + if success: + # Verify the token works by initializing toolkit + try: + toolkit = LinkedInToolkit("install_auth") + tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()] + profile = toolkit.get_profile_safe() + + return { + "success": True, + "message": "LinkedIn token saved successfully", + "tools": tools, + "count": len(tools), + "profile": profile if "error" not in profile else None + } + except Exception as e: + logger.warning(f"Token saved but verification failed: {e}") + return { + "success": True, + "message": "LinkedIn token saved (verification pending)", + "warning": str(e) + } + else: + raise HTTPException( + status_code=500, + detail="Failed to save LinkedIn token" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to save LinkedIn token: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to save token: {str(e)}" + ) + + +@router.get("/linkedin/status", name="get linkedin status") +async def get_linkedin_status(): + r"""Get current LinkedIn authentication status and token info. + + Returns: + Status information including authentication state and token expiry + """ + try: + is_authenticated = LinkedInToolkit.is_authenticated() + + if not is_authenticated: + return { + "authenticated": False, + "status": "not_configured", + "message": "LinkedIn not configured. OAuth required.", + "oauth_url": "/api/oauth/linkedin/login" + } + + token_info = LinkedInToolkit.get_token_info() + is_expired = LinkedInToolkit.is_token_expired() + is_expiring_soon = LinkedInToolkit.is_token_expiring_soon() + + result = { + "authenticated": True, + "status": "expired" if is_expired else ("expiring_soon" if is_expiring_soon else "valid"), + } + + if token_info: + if token_info.get("expires_at"): + current_time = int(time.time()) + expires_at = token_info["expires_at"] + seconds_remaining = max(0, expires_at - current_time) + days_remaining = seconds_remaining // (24 * 60 * 60) + result["expires_at"] = expires_at + result["days_remaining"] = days_remaining + + if token_info.get("saved_at"): + result["saved_at"] = token_info["saved_at"] + + if is_expired: + result["message"] = "Token has expired. Please re-authenticate." + result["oauth_url"] = "/api/oauth/linkedin/login" + elif is_expiring_soon: + result["message"] = f"Token expires in {result.get('days_remaining', 'unknown')} days. Consider re-authenticating." + result["oauth_url"] = "/api/oauth/linkedin/login" + else: + result["message"] = "LinkedIn is connected and token is valid." + + return result + except Exception as e: + logger.error(f"Failed to get LinkedIn status: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to get status: {str(e)}" ) diff --git a/backend/app/utils/toolkit/linkedin_toolkit.py b/backend/app/utils/toolkit/linkedin_toolkit.py index a445d4e2..105fbfce 100644 --- a/backend/app/utils/toolkit/linkedin_toolkit.py +++ b/backend/app/utils/toolkit/linkedin_toolkit.py @@ -12,6 +12,12 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import json +import os +import logging +import time +from typing import Optional + from camel.toolkits import LinkedInToolkit as BaseLinkedInToolkit from camel.toolkits.function_tool import FunctionTool from app.component.environment import env @@ -19,18 +25,258 @@ from app.service.task import Agents from app.utils.listen.toolkit_listen import auto_listen_toolkit from app.utils.toolkit.abstract_toolkit import AbstractToolkit +logger = logging.getLogger("linkedin_toolkit") + +# LinkedIn access tokens expire after 60 days (in seconds) +LINKEDIN_TOKEN_LIFETIME_SECONDS = 60 * 24 * 60 * 60 +# Refresh token when less than 7 days remaining +LINKEDIN_TOKEN_REFRESH_THRESHOLD_SECONDS = 7 * 24 * 60 * 60 + @auto_listen_toolkit(BaseLinkedInToolkit) class LinkedInToolkit(BaseLinkedInToolkit, AbstractToolkit): + r"""LinkedIn toolkit for social media automation. + + This toolkit wraps CAMEL-AI's LinkedInToolkit and provides: + - Post creation and deletion + - Profile information retrieval + - Token file persistence + - Environment variable fallback + """ + agent_name: str = Agents.social_medium_agent def __init__(self, api_task_id: str, timeout: float | None = None): - super().__init__(timeout) self.api_task_id = api_task_id + self._token_path = self._build_canonical_token_path() + self._load_credentials() + super().__init__(timeout) + + @classmethod + def _build_canonical_token_path(cls) -> str: + r"""Build the canonical path for storing LinkedIn tokens.""" + return env("LINKEDIN_TOKEN_PATH") or os.path.join( + os.path.expanduser("~"), + ".eigent", + "tokens", + "linkedin", + "linkedin_token.json", + ) + + def _load_credentials(self): + r"""Load credentials from token file or environment variables.""" + from dotenv import load_dotenv + + # Force reload environment variables from default .env file + default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env") + if os.path.exists(default_env_path): + load_dotenv(dotenv_path=default_env_path, override=True) + + # Try to load from token file first + if os.path.exists(self._token_path): + try: + with open(self._token_path, "r") as f: + token_data = json.load(f) + access_token = token_data.get("access_token") + if access_token: + # Check if token is expired before loading + expires_at = token_data.get("expires_at") + if expires_at and int(time.time()) >= expires_at: + logger.warning("LinkedIn token file contains expired token, skipping load") + else: + os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token + logger.info(f"Loaded LinkedIn credentials from token file: {self._token_path}") + except Exception as e: + logger.warning(f"Could not load LinkedIn token file: {e}") @classmethod def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]: + r"""Return tools only if LinkedIn credentials are configured.""" + from dotenv import load_dotenv + + # Force reload environment variables + default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env") + if os.path.exists(default_env_path): + load_dotenv(dotenv_path=default_env_path, override=True) + + # Check for token file + token_path = cls._build_canonical_token_path() + if os.path.exists(token_path): + try: + with open(token_path, "r") as f: + token_data = json.load(f) + access_token = token_data.get("access_token") + if access_token: + # Check if token is expired before loading + expires_at = token_data.get("expires_at") + if expires_at and int(time.time()) >= expires_at: + logger.warning("LinkedIn token file contains expired token, skipping load") + else: + os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token + except Exception: + pass + if env("LINKEDIN_ACCESS_TOKEN"): return LinkedInToolkit(api_task_id).get_tools() else: return [] + + @classmethod + def save_token(cls, token_data: dict) -> bool: + r"""Save OAuth token to file. + + Args: + token_data: Dictionary containing access_token and optionally refresh_token + + Returns: + True if saved successfully, False otherwise + """ + token_path = cls._build_canonical_token_path() + try: + # Add timestamp for expiration tracking if not present + if "saved_at" not in token_data: + token_data["saved_at"] = int(time.time()) + + # Calculate expiration time if expires_in is provided + if "expires_in" in token_data and "expires_at" not in token_data: + token_data["expires_at"] = token_data["saved_at"] + token_data["expires_in"] + elif "expires_at" not in token_data: + # Default to 60 days if no expiration info provided + token_data["expires_at"] = token_data["saved_at"] + LINKEDIN_TOKEN_LIFETIME_SECONDS + + os.makedirs(os.path.dirname(token_path), exist_ok=True) + with open(token_path, "w") as f: + json.dump(token_data, f, indent=2) + logger.info(f"Saved LinkedIn token to {token_path}") + + # Also update environment variable + if token_data.get("access_token"): + os.environ["LINKEDIN_ACCESS_TOKEN"] = token_data["access_token"] + + return True + except Exception as e: + logger.error(f"Failed to save LinkedIn token: {e}") + return False + + @classmethod + def clear_token(cls) -> bool: + r"""Remove stored token file and clear environment variable. + + Returns: + True if cleared successfully, False otherwise + """ + token_path = cls._build_canonical_token_path() + try: + if os.path.exists(token_path): + os.remove(token_path) + logger.info(f"Removed LinkedIn token file: {token_path}") + + # Also try to remove the parent directory if empty + token_dir = os.path.dirname(token_path) + if os.path.exists(token_dir) and not os.listdir(token_dir): + os.rmdir(token_dir) + logger.info(f"Removed empty LinkedIn token directory: {token_dir}") + + # Clear environment variable + if "LINKEDIN_ACCESS_TOKEN" in os.environ: + del os.environ["LINKEDIN_ACCESS_TOKEN"] + + return True + except Exception as e: + logger.error(f"Failed to clear LinkedIn token: {e}") + return False + + @classmethod + def is_authenticated(cls) -> bool: + r"""Check if user has valid LinkedIn credentials. + + Returns: + True if credentials are available, False otherwise + """ + from dotenv import load_dotenv + + # Force reload environment variables + default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env") + if os.path.exists(default_env_path): + load_dotenv(dotenv_path=default_env_path, override=True) + + # Check token file + token_path = cls._build_canonical_token_path() + if os.path.exists(token_path): + try: + with open(token_path, "r") as f: + token_data = json.load(f) + if token_data.get("access_token"): + return True + except Exception: + pass + + # Check environment variable + return bool(env("LINKEDIN_ACCESS_TOKEN")) + + @classmethod + def get_token_info(cls) -> Optional[dict]: + r"""Get stored token information including expiration. + + Returns: + Token data dictionary or None if not available + """ + token_path = cls._build_canonical_token_path() + if os.path.exists(token_path): + try: + with open(token_path, "r") as f: + return json.load(f) + except Exception: + pass + return None + + @classmethod + def is_token_expiring_soon(cls) -> bool: + r"""Check if token is expiring within the refresh threshold. + + Returns: + True if token is expiring soon or already expired, False otherwise. + Returns False if no token file exists (e.g. env var auth only). + """ + token_info = cls.get_token_info() + if not token_info: + return False # No token file; cannot determine expiry, assume valid + + expires_at = token_info.get("expires_at") + if not expires_at: + return False # No expiration info, assume valid + + current_time = int(time.time()) + time_remaining = expires_at - current_time + + return time_remaining < LINKEDIN_TOKEN_REFRESH_THRESHOLD_SECONDS + + @classmethod + def is_token_expired(cls) -> bool: + r"""Check if token has expired. + + Returns: + True if token is expired, False otherwise. + Returns False if no token file exists (e.g. env var auth only). + """ + token_info = cls.get_token_info() + if not token_info: + return False # No token file; cannot determine expiry, assume valid + + expires_at = token_info.get("expires_at") + if not expires_at: + return False # No expiration info, assume valid + + return int(time.time()) >= expires_at + + def get_profile_safe(self) -> dict: + r"""Get LinkedIn profile with error handling. + + Returns: + Profile dictionary or error information + """ + try: + return self.get_profile(include_id=True) + except Exception as e: + logger.error(f"Failed to get LinkedIn profile: {e}") + return {"error": str(e)} diff --git a/package.json b/package.json index 354df31a..53536056 100644 --- a/package.json +++ b/package.json @@ -75,12 +75,14 @@ "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.17.0", + "github-markdown-css": "^5.8.1", "gsap": "^3.13.0", "koffi": "^2.14.1", "lodash-es": "^4.17.21", "lottie-web": "^5.13.0", "lucide-react": "^0.509.0", "mammoth": "^1.9.1", + "marked": "^17.0.1", "mime": "^4.1.0", "monaco-editor": "^0.52.2", "motion": "^12.23.24", diff --git a/server/app/component/oauth_adapter.py b/server/app/component/oauth_adapter.py index 75788caa..4e729ffa 100644 --- a/server/app/component/oauth_adapter.py +++ b/server/app/component/oauth_adapter.py @@ -1,19 +1,20 @@ -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= - +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + from abc import ABC, abstractmethod from typing import Dict, Any, Optional +from urllib.parse import urlencode import os import httpx from pydantic import BaseModel @@ -191,6 +192,60 @@ class EXAOAuthAdapter(OAuthAdapter): return None +class LinkedInOAuthAdapter(OAuthAdapter): + r"""LinkedIn OAuth 2.0 adapter for 3-legged OAuth flow.""" + + AUTHORIZATION_URL = "https://www.linkedin.com/oauth/v2/authorization" + TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" + SCOPES = "openid profile email w_member_social" + + def __init__(self, redirect_uri: Optional[str] = None): + self.client_id = env("LINKEDIN_CLIENT_ID", "") + self.client_secret = env("LINKEDIN_CLIENT_SECRET", "") + self.redirect_uri = redirect_uri or env( + "LINKEDIN_REDIRECT_URI", "https://localhost/api/oauth/linkedin/callback" + ) + + def get_authorize_url(self, state: Optional[str] = None) -> Optional[str]: + params = { + "response_type": "code", + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "scope": self.SCOPES, + } + if state: + params["state"] = state + return f"{self.AUTHORIZATION_URL}?{urlencode(params)}" + + def fetch_token(self, code: Optional[str]) -> Optional[Dict[str, Any]]: + if not code: + return None + headers = {"Content-Type": "application/x-www-form-urlencoded"} + data = { + "grant_type": "authorization_code", + "code": code, + "client_id": self.client_id, + "client_secret": self.client_secret, + "redirect_uri": self.redirect_uri, + } + with httpx.Client() as client: + resp = client.post(self.TOKEN_URL, headers=headers, data=data) + return resp.json() + + def refresh_token(self, refresh_token: str) -> Optional[Dict[str, Any]]: + r"""Refresh an expired access token.""" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": self.client_id, + "client_secret": self.client_secret, + } + with httpx.Client() as client: + resp = client.post(self.TOKEN_URL, headers=headers, data=data) + return resp.json() + + # 工厂方法 OAUTH_ADAPTERS = { "slack": SlackOAuthAdapter, @@ -198,20 +253,16 @@ OAUTH_ADAPTERS = { "x": XOAuthAdapter, "twitter": XOAuthAdapter, "googlesuite": GoogleSuiteOAuthAdapter, + "linkedin": LinkedInOAuthAdapter, } def get_oauth_adapter(app_name: str, redirect_uri: Optional[str] = None) -> OAuthAdapter: adapter_cls = OAUTH_ADAPTERS.get(app_name.lower()) if not adapter_cls: - raise ValueError(f"不支持的OAuth应用: {app_name}") - if app_name.lower() == "slack": - return adapter_cls(redirect_uri=redirect_uri) - if app_name.lower() == "notion": - return adapter_cls(redirect_uri=redirect_uri) - if app_name.lower() == "x" or app_name.lower() == "twitter": - return adapter_cls(redirect_uri=redirect_uri) - return adapter_cls() + raise ValueError(f"Unsupported OAuth application: {app_name}") + # All adapters support redirect_uri parameter + return adapter_cls(redirect_uri=redirect_uri) class OauthCallbackPayload(BaseModel): diff --git a/server/app/model/config/config.py b/server/app/model/config/config.py index 8b681b74..aea087a4 100644 --- a/server/app/model/config/config.py +++ b/server/app/model/config/config.py @@ -79,7 +79,12 @@ class ConfigInfo: "toolkit": "whatsapp_toolkit", }, ConfigGroup.LINKEDIN.value: { - "env_vars": ["LINKEDIN_ACCESS_TOKEN"], + "env_vars": [ + "LINKEDIN_CLIENT_ID", + "LINKEDIN_CLIENT_SECRET", + "LINKEDIN_ACCESS_TOKEN", + "LINKEDIN_REFRESH_TOKEN", + ], "toolkit": "linkedin_toolkit", }, ConfigGroup.REDDIT.value: { diff --git a/src/assets/wechat_qr.jpg b/src/assets/wechat_qr.jpg index e0710f61..7a2f1f29 100644 Binary files a/src/assets/wechat_qr.jpg and b/src/assets/wechat_qr.jpg differ diff --git a/src/components/ChatBox/MessageItem/MarkDown.tsx b/src/components/ChatBox/MessageItem/MarkDown.tsx index 08855a1f..d9a8e3c8 100644 --- a/src/components/ChatBox/MessageItem/MarkDown.tsx +++ b/src/components/ChatBox/MessageItem/MarkDown.tsx @@ -12,232 +12,242 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -import { useState, useEffect, memo, useRef } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { isHtmlDocument } from "@/lib/htmlFontStyles"; +import { useState, useEffect, memo, useRef } from 'react'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; +import { isHtmlDocument } from '@/lib/htmlFontStyles'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import '@/style/markdown-styles.css'; + +// Helper functions for path resolution +function joinPath(...paths: string[]): string { + return paths + .filter(Boolean) + .map((p) => p.replace(/\\/g, '/')) + .join('/') + .replace(/\/+/g, '/'); +} + +function resolveRelativePath(basePath: string, relativePath: string): string { + const normalizedBase = basePath.replace(/\\/g, '/'); + const normalizedRelative = relativePath.replace(/\\/g, '/'); + if ( + !normalizedRelative.startsWith('./') && + !normalizedRelative.startsWith('../') + ) { + return joinPath(normalizedBase, normalizedRelative); + } + const baseParts = normalizedBase.split('/').filter(Boolean); + const relativeParts = normalizedRelative.split('/').filter(Boolean); + for (const part of relativeParts) { + if (part === '.') continue; + if (part === '..') baseParts.pop(); + else baseParts.push(part); + } + return baseParts.join('/'); +} + +// Configure marked +marked.setOptions({ + gfm: true, + breaks: true, +}); export const MarkDown = memo( - ({ - content, - speed = 10, - onTyping, - enableTypewriter = true, // Whether to enable typewriter effect - pTextSize = "text-body-sm", - olPadding = "pl-3", - }: { - content: string; - speed?: number; - onTyping?: () => void; - enableTypewriter?: boolean; - pTextSize?: string; - olPadding?: string; - }) => { - const [displayedContent, setDisplayedContent] = useState(""); - const lastContentRef = useRef(null); - const typingCallbackRef = useRef(onTyping); + ({ + content, + speed = 10, + onTyping, + enableTypewriter = true, + contentBasePath, + }: { + content: string; + speed?: number; + onTyping?: () => void; + enableTypewriter?: boolean; + pTextSize?: string; + olPadding?: string; + /** Base directory for resolving relative image paths (e.g. markdown file's directory). */ + contentBasePath?: string | null; + }) => { + const [displayedContent, setDisplayedContent] = useState(''); + const [html, setHtml] = useState(''); + const [previewImage, setPreviewImage] = useState(null); + const contentRef = useRef(null); + const lastContentRef = useRef(null); + const typingCallbackRef = useRef(onTyping); - useEffect(() => { - typingCallbackRef.current = onTyping; - }, [onTyping]); + useEffect(() => { + typingCallbackRef.current = onTyping; + }, [onTyping]); - useEffect(() => { - if (lastContentRef.current === content) { - return; - } - lastContentRef.current = content; + // Typewriter effect + useEffect(() => { + if (lastContentRef.current === content) { + return; + } + lastContentRef.current = content; - if (!enableTypewriter) { - setDisplayedContent(content); - if (typingCallbackRef.current) { - typingCallbackRef.current(); - } - return; - } + if (!enableTypewriter) { + setDisplayedContent(content); + if (typingCallbackRef.current) { + typingCallbackRef.current(); + } + return; + } - setDisplayedContent(""); - let index = 0; + setDisplayedContent(''); + let index = 0; - const timer = setInterval(() => { - if (index < content.length) { - setDisplayedContent(content.slice(0, index + 1)); - index++; - } else { - clearInterval(timer); - // when typewriter effect is completed, call callback - if (typingCallbackRef.current) { - typingCallbackRef.current(); - } - } - }, speed); + const timer = setInterval(() => { + if (index < content.length) { + setDisplayedContent(content.slice(0, index + 1)); + index++; + } else { + clearInterval(timer); + if (typingCallbackRef.current) { + typingCallbackRef.current(); + } + } + }, speed); - return () => clearInterval(timer); - }, [content, speed, enableTypewriter]); + return () => clearInterval(timer); + }, [content, speed, enableTypewriter]); - // If content is a pure HTML document, render in a styled pre block - if (isHtmlDocument(content)) { - // Trim leading whitespace from each line for consistent alignment - const formattedHtml = displayedContent - .split('\n') - .map(line => line.trimStart()) - .join('\n') - .trim(); - return ( -
-
-						{formattedHtml}
-					
-
- ); - } + // Convert markdown to HTML and process images + useEffect(() => { + const processMarkdown = async () => { + if (!displayedContent) { + setHtml(''); + return; + } - return ( -
- ( -

- {children} -

- ), - h2: ({ children }) => ( -

- {children} -

- ), - h3: ({ children }) => ( -

- {children} -

- ), - p: ({ children }) => ( -

- {children} -

- ), - ul: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • {children}
  • - ), - code: ({ children }) => ( - - {children} - - ), - pre: ({ children }) => ( -
    -								{children}
    -							
    - ), - blockquote: ({ children }) => ( -
    - {children} -
    - ), - strong: ({ children }) => ( - {children} - ), - em: ({ children }) => ( - {children} - ), - a: ({ children, href }) => { - const cleanChildren = typeof children === 'string' - ? children.replace(/^[.,"'{}()\[\]]+|[.,"'{}()\[\]]+$/g, '') - : children; - const cleanHref = typeof href === 'string' - ? href.replace(/^[.,"'{}()\[\]]+|[.,"'{}()\[\]]+$/g, '').replace(/(%[0-9A-Fa-f]{2})+$/g, '') - : href; - return ( - - {cleanChildren} - - ); - }, - table: ({ children }) => ( -
    - - {children} -
    -
    - ), - thead: ({ children }) => ( - - {children} - - ), - tbody: ({ children }) => ( - {children} - ), - tr: ({ children }) => {children}, - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ), - }} - > - {displayedContent} -
    -
    - ); - } + // If content is pure HTML, handle it separately + if (isHtmlDocument(displayedContent)) { + const formattedHtml = displayedContent + .split('\n') + .map((line) => line.trimStart()) + .join('\n') + .trim(); + setHtml( + `
    ${DOMPurify.sanitize(formattedHtml)}
    `, + ); + return; + } + + // Parse markdown to HTML + let rawHtml = await marked.parse(displayedContent); + + // Process images: replace relative paths with data URLs + if (contentBasePath) { + const imgRegex = /]*?)src=["']([^"']+)["']([^>]*?)>/gi; + const matches = Array.from(rawHtml.matchAll(imgRegex)); + + for (const match of matches) { + const fullTag = match[0]; + const beforeSrc = match[1]; + const src = match[2]; + const afterSrc = match[3]; + + // Check if it's a relative path + const isRelative = + src && + !src.startsWith('http://') && + !src.startsWith('https://') && + !src.startsWith('data:'); + + if (isRelative && contentBasePath) { + try { + const resolvedPath = resolveRelativePath(contentBasePath, src); + + if ( + typeof window !== 'undefined' && + window.electronAPI?.readFileAsDataUrl + ) { + const dataUrl = + await window.electronAPI.readFileAsDataUrl(resolvedPath); + + // Add cursor-pointer class and data attributes for click handling + const newTag = ``; + rawHtml = rawHtml.replace(fullTag, newTag); + } else { + // Fallback: show alt text or placeholder + const altMatch = fullTag.match(/alt=["']([^"']*)["']/); + const alt = altMatch ? altMatch[1] : 'image'; + const placeholder = `[${alt}]`; + rawHtml = rawHtml.replace(fullTag, placeholder); + } + } catch (error) { + console.error(`Failed to load image: ${src}`, error); + // Keep original tag if loading fails + } + } else { + // For absolute URLs, add click handler + const newTag = fullTag.replace( + ' { + if (!contentRef.current) return; + + const handleImageClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if ( + target.tagName === 'IMG' && + target.getAttribute('data-clickable') === 'true' + ) { + const src = (target as HTMLImageElement).src; + setPreviewImage(src); + } + }; + + const div = contentRef.current; + div.addEventListener('click', handleImageClick); + + return () => { + div.removeEventListener('click', handleImageClick); + }; + }, [html]); + + return ( + <> +
    + + {/* Image preview dialog */} + setPreviewImage(null)}> + + {previewImage && ( + Preview + )} + + + + ); + }, ); diff --git a/src/components/Folder/index.tsx b/src/components/Folder/index.tsx index c564ff8b..11a0f5af 100644 --- a/src/components/Folder/index.tsx +++ b/src/components/Folder/index.tsx @@ -261,7 +261,10 @@ export default function Folder({ data }: { data?: Agent }) { for (const file of sortedFiles) { // Normalize paths to use forward slashes for cross-platform compatibility - const normalizedRelativePath = (file.relativePath || '').replace(/\\/g, '/'); + const normalizedRelativePath = (file.relativePath || '').replace( + /\\/g, + '/', + ); const fullRelativePath = normalizedRelativePath ? `${normalizedRelativePath}/${file.name}` : file.name; @@ -297,7 +300,7 @@ export default function Folder({ data }: { data?: Agent }) { }); const [expandedFolders, setExpandedFolders] = useState>( - new Set() + new Set(), ); const toggleFolder = (folderPath: string) => { @@ -337,7 +340,7 @@ export default function Folder({ data }: { data?: Agent }) { res = await window.ipcRenderer.invoke( 'get-project-file-list', authStore.email, - projectStore.activeProjectId as string + projectStore.activeProjectId as string, ); let tree: any = null; if ( @@ -374,7 +377,7 @@ export default function Folder({ data }: { data?: Agent }) { if (chatStoreSelectedFile) { console.log(res, chatStoreSelectedFile); const file = res.find( - (item: any) => item.name === chatStoreSelectedFile.name + (item: any) => item.name === chatStoreSelectedFile.name, ); console.log('file', file); if (file && selectedFile?.path !== chatStoreSelectedFile?.path) { @@ -397,7 +400,7 @@ export default function Folder({ data }: { data?: Agent }) { chatStore.tasks[chatStore.activeTaskId as string]?.selectedFile; if (chatStoreSelectedFile && fileGroups[0]?.files) { const file = fileGroups[0].files.find( - (item: any) => item.path === chatStoreSelectedFile.path + (item: any) => item.path === chatStoreSelectedFile.path, ); if (file && selectedFile?.path !== chatStoreSelectedFile?.path) { selectedFileChange(file as FileInfo, isShowSourceCode); @@ -517,7 +520,7 @@ export default function Folder({ data }: { data?: Agent }) { )} - )) + )), )}
    )} @@ -539,7 +542,7 @@ export default function Folder({ data }: { data?: Agent }) { } window.ipcRenderer.invoke( 'reveal-in-folder', - selectedFile.path + selectedFile.path, ); }} className="flex-1 min-w-0 overflow-hidden cursor-pointer flex items-center gap-2" @@ -564,8 +567,12 @@ export default function Folder({ data }: { data?: Agent }) { )} {/* content */} -
    -
    +
    +
    {selectedFile ? ( !loading ? ( selectedFile.type === 'md' && !isShowSourceCode ? ( @@ -573,6 +580,11 @@ export default function Folder({ data }: { data?: Agent }) {
    ) : selectedFile.type === 'pdf' ? ( @@ -582,7 +594,7 @@ export default function Folder({ data }: { data?: Agent }) { title={selectedFile.name} /> ) : ['csv', 'doc', 'docx', 'pptx', 'xlsx'].includes( - selectedFile.type + selectedFile.type, ) ? ( ) : selectedFile.type === 'html' ? ( @@ -691,7 +703,10 @@ function resolveRelativePath(basePath: string, relativePath: string): string { const normalizedRelative = relativePath.replace(/\\/g, '/'); // If it's not a relative path, return as-is - if (!normalizedRelative.startsWith('./') && !normalizedRelative.startsWith('../')) { + if ( + !normalizedRelative.startsWith('./') && + !normalizedRelative.startsWith('../') + ) { // It's a simple relative path like "script.js" or "js/script.js" return joinPath(normalizedBase, normalizedRelative); } @@ -740,13 +755,18 @@ function HtmlRenderer({ // Parse HTML to find referenced JS and CSS files via relative paths const scriptSrcRegex = /]*src\s*=\s*["']([^"']+)["'][^>]*>/gi; - const linkHrefRegex = /]*href\s*=\s*["']([^"']+\.css)["'][^>]*>/gi; + const linkHrefRegex = + /]*href\s*=\s*["']([^"']+\.css)["'][^>]*>/gi; const referencedPaths: Set = new Set(); // Helper to extract and resolve paths const addReferencedPath = (url: string) => { - if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('//')) { + if ( + !url.startsWith('http://') && + !url.startsWith('https://') && + !url.startsWith('//') + ) { const resolvedPath = resolveRelativePath(htmlDir, url); referencedPaths.add(resolvedPath.toLowerCase()); } @@ -766,13 +786,21 @@ function HtmlRenderer({ // Find matching files (exact path match only) const relatedFiles = projectFiles.filter((file) => { - if (file.isFolder || !['js', 'css'].includes(file.type?.toLowerCase() || '')) return false; + if ( + file.isFolder || + !['js', 'css'].includes(file.type?.toLowerCase() || '') + ) + return false; const normalizedFilePath = file.path.replace(/\\/g, '/').toLowerCase(); return referencedPaths.has(normalizedFilePath); }); - const jsFiles = relatedFiles.filter((f) => f.type?.toLowerCase() === 'js'); - const cssFiles = relatedFiles.filter((f) => f.type?.toLowerCase() === 'css'); + const jsFiles = relatedFiles.filter( + (f) => f.type?.toLowerCase() === 'js', + ); + const cssFiles = relatedFiles.filter( + (f) => f.type?.toLowerCase() === 'css', + ); // Check for dangerous Electron/Node.js patterns as defense-in-depth if (containsDangerousContent(html)) { @@ -819,14 +847,13 @@ function HtmlRenderer({ try { // Read image as data URL - const dataUrl = await window.electronAPI.readFileAsDataUrl( - imagePath - ); + const dataUrl = + await window.electronAPI.readFileAsDataUrl(imagePath); // Replace src with data URL const newAttributes = attributes.replace( /src\s*=\s*["'][^"']+["']/i, - `src="${dataUrl}"` + `src="${dataUrl}"`, ); // Preserve the original tag format (self-closing or not) const isSelfClosing = imgTag.trim().endsWith('/>'); @@ -840,7 +867,7 @@ function HtmlRenderer({ // Keep original tag if image loading fails return { original: imgTag, processed: imgTag }; } - }) + }), ); // Replace all img tags in HTML @@ -848,7 +875,7 @@ function HtmlRenderer({ processedImages.forEach(({ original, processed }) => { processedHtmlContent = processedHtmlContent.replace( original, - processed + processed, ); }); @@ -859,17 +886,20 @@ function HtmlRenderer({ 'open-file', 'css', cssFile.path, - false + false, ); if (cssContent) { const styleTag = ``; - + // Try to replace the external link tag with inline style const linkRegex = new RegExp( `]*href=["'](?:[^"']*[/\\\\])?${cssFile.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*>`, - 'gi' + 'gi', + ); + const replacedCss = processedHtmlContent.replace( + linkRegex, + styleTag, ); - const replacedCss = processedHtmlContent.replace(linkRegex, styleTag); if (replacedCss !== processedHtmlContent) { processedHtmlContent = replacedCss; } else { @@ -877,7 +907,7 @@ function HtmlRenderer({ if (processedHtmlContent.includes('')) { processedHtmlContent = processedHtmlContent.replace( '', - `${styleTag}` + `${styleTag}`, ); } else { processedHtmlContent = styleTag + processedHtmlContent; @@ -896,16 +926,19 @@ function HtmlRenderer({ 'open-file', 'js', jsFile.path, - false + false, ); if (jsContent) { // Replace external script tag with inline script const scriptRegex = new RegExp( `]*src=["'](?:[^"']*[/\\\\])?${jsFile.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*>\\s*`, - 'gi' + 'gi', ); const inlineScriptTag = ``; - processedHtmlContent = processedHtmlContent.replace(scriptRegex, inlineScriptTag); + processedHtmlContent = processedHtmlContent.replace( + scriptRegex, + inlineScriptTag, + ); } } catch (error) { console.error(`Failed to load JS file: ${jsFile.path}`, error); diff --git a/src/components/IntegrationList/index.tsx b/src/components/IntegrationList/index.tsx index 626f0847..3947222a 100644 --- a/src/components/IntegrationList/index.tsx +++ b/src/components/IntegrationList/index.tsx @@ -38,6 +38,7 @@ import { useIntegrationManagement, type IntegrationItem, } from "@/hooks/useIntegrationManagement"; +import { getProxyBaseURL } from "@/lib"; type IntegrationListVariant = "select" | "manage"; @@ -124,6 +125,15 @@ export default function IntegrationList({ return; } + // LinkedIn uses server-side OAuth flow + if (item.key === "LinkedIn") { + // Open LinkedIn OAuth login via the remote server (same pattern as other OAuth providers) + const baseUrl = getProxyBaseURL(); + const oauthUrl = `${baseUrl}/api/oauth/linkedin/login`; + window.open(oauthUrl, "_blank", "width=600,height=700"); + return; + } + if (installed[item.key]) return; await item.onInstall(); // Only refresh in select mode @@ -256,7 +266,7 @@ export default function IntegrationList({ "Slack", "X(Twitter)", "WhatsApp", - "LinkedIn", + // "LinkedIn", // LinkedIn OAuth is now supported "Reddit", "Github", ]; diff --git a/src/hooks/useIntegrationManagement.ts b/src/hooks/useIntegrationManagement.ts index 74239301..5be16a04 100644 --- a/src/hooks/useIntegrationManagement.ts +++ b/src/hooks/useIntegrationManagement.ts @@ -19,6 +19,7 @@ import { proxyFetchPut, proxyFetchDelete, fetchDelete, + fetchPost, } from "@/api/http"; import { capitalizeFirstLetter } from "@/lib"; import { useAuthStore } from "@/store/authStore"; @@ -75,7 +76,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) { // Recalculate installed status when items or configs change useEffect(() => { const map: { [key: string]: boolean } = {}; - + items.forEach((item) => { if (item.key === "Google Calendar") { // Only mark installed when refresh token is present (auth completed) @@ -86,6 +87,15 @@ export function useIntegrationManagement(items: IntegrationItem[]) { c.config_value && String(c.config_value).length > 0 ); map[item.key] = hasRefreshToken; + } else if (item.key === "LinkedIn") { + // LinkedIn: check if access token is present + const hasAccessToken = configs.some( + (c: any) => + c.config_group?.toLowerCase() === "linkedin" && + c.config_name === "LINKEDIN_ACCESS_TOKEN" && + c.config_value && String(c.config_value).length > 0 + ); + map[item.key] = hasAccessToken; } else { // For other integrations, use config_group presence const hasConfig = configs.some( @@ -94,7 +104,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) { map[item.key] = hasConfig; } }); - + setInstalled(map); }, [items, configs]); @@ -187,6 +197,43 @@ export function useIntegrationManagement(items: IntegrationItem[]) { "Slack authorization successful, but access_token not found or env configuration not found" ); } + } else if (provider === "linkedin") { + // LinkedIn OAuth: save token via local backend endpoint and config + if (tokenResult.access_token) { + try { + // Save token to local backend toolkit (token file is stored locally) + await fetchPost("/linkedin/save-token", { + access_token: tokenResult.access_token, + refresh_token: tokenResult.refresh_token, + expires_in: tokenResult.expires_in, + }); + + // Also save to config for UI status tracking + await saveEnvAndConfig( + "LinkedIn", + "LINKEDIN_ACCESS_TOKEN", + tokenResult.access_token + ); + if (tokenResult.refresh_token) { + await saveEnvAndConfig( + "LinkedIn", + "LINKEDIN_REFRESH_TOKEN", + tokenResult.refresh_token + ); + } + + await fetchInstalled(); + console.log( + "LinkedIn authorization successful and configuration saved!" + ); + } catch (e) { + console.error("Failed to save LinkedIn token:", e); + } + } else { + console.log( + "LinkedIn authorization successful, but access_token not found" + ); + } } } catch (e: any) { console.log(`${data.provider} authorization failed: ${e.message || e}`); @@ -254,7 +301,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) { } } - // Clean up authentication tokens for Google Calendar and Notion + // Clean up authentication tokens for Google Calendar, Notion, and LinkedIn if (item.key === "Google Calendar") { try { await fetchDelete("/uninstall/tool/google_calendar"); @@ -269,6 +316,13 @@ export function useIntegrationManagement(items: IntegrationItem[]) { } catch (e) { console.log("Failed to clean up Notion tokens:", e); } + } else if (item.key === "LinkedIn") { + try { + await fetchDelete("/uninstall/tool/linkedin"); + console.log("Cleaned up LinkedIn authentication tokens"); + } catch (e) { + console.log("Failed to clean up LinkedIn tokens:", e); + } } // Update configs after deletion diff --git a/src/style/markdown-styles.css b/src/style/markdown-styles.css new file mode 100644 index 00000000..4649d58b --- /dev/null +++ b/src/style/markdown-styles.css @@ -0,0 +1,224 @@ +/* Import GitHub markdown styles */ +@import 'github-markdown-css/github-markdown.css'; +/* Custom overrides for better integration */ +.markdown-body { + box-sizing: border-box; + min-width: 200px; + max-width: 100%; + padding: 0; + font-size: 0.875rem; /* 14px */ + line-height: 1.6; + word-wrap: break-word; + color: inherit; +} + +/* Typography improvements */ +.markdown-body h1 { + font-size: 1.5em; + font-weight: 700; + margin-bottom: 0.5em; + margin-top: 1em; + padding-bottom: 0.3em; + border-bottom: 1px solid #e5e7eb; +} + +.markdown-body h2 { + font-size: 1.25em; + font-weight: 600; + margin-bottom: 0.5em; + margin-top: 1em; + padding-bottom: 0.3em; + border-bottom: 1px solid #e5e7eb; +} + +.markdown-body h3 { + font-size: 1.1em; + font-weight: 600; + margin-bottom: 0.5em; + margin-top: 1em; +} + +.markdown-body p { + margin-bottom: 0.75em; + margin-top: 0; +} + +/* Code blocks styling */ +.markdown-body pre { + background-color: #f6f8fa; + border-radius: 6px; + padding: 16px; + overflow-x: auto; + font-size: 13px; + line-height: 1.45; + margin-bottom: 16px; +} + +.markdown-body code { + background-color: rgba(175, 184, 193, 0.2); + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 85%; + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; +} + +.markdown-body pre code { + background-color: transparent; + padding: 0; + font-size: 100%; +} + +/* Table styling */ +.markdown-body table { + border-collapse: collapse; + border-spacing: 0; + width: 100%; + overflow: auto; + margin-bottom: 16px; +} + +.markdown-body table th, +.markdown-body table td { + padding: 6px 13px; + border: 1px solid #d0d7de; +} + +.markdown-body table th { + font-weight: 600; + background-color: #f6f8fa; +} + +.markdown-body table tr { + background-color: #ffffff; + border-top: 1px solid #d0d7de; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +/* List styling */ +.markdown-body ul, +.markdown-body ol { + margin-bottom: 16px; + padding-left: 2em; +} + +.markdown-body li { + margin-bottom: 0.25em; +} + +.markdown-body li > p { + margin-bottom: 0; +} + +/* Blockquote styling */ +.markdown-body blockquote { + padding: 0 1em; + color: #57606a; + border-left: 0.25em solid #d0d7de; + margin-bottom: 16px; +} + +/* Link styling */ +.markdown-body a { + color: #0969da; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +/* Image styling */ +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: 6px; + margin: 8px 0; +} + +/* Horizontal rule */ +.markdown-body hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: #d0d7de; + border: 0; +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .markdown-body { + color: #e6edf3; + } + + .markdown-body h1, + .markdown-body h2 { + border-bottom-color: #30363d; + } + + .markdown-body pre { + background-color: #161b22; + } + + .markdown-body code { + background-color: rgba(110, 118, 129, 0.4); + } + + .markdown-body table th { + background-color: #161b22; + } + + .markdown-body table th, + .markdown-body table td { + border-color: #30363d; + } + + .markdown-body table tr { + background-color: transparent; + border-top-color: #30363d; + } + + .markdown-body table tr:nth-child(2n) { + background-color: rgba(110, 118, 129, 0.1); + } + + .markdown-body blockquote { + color: #8b949e; + border-left-color: #30363d; + } + + .markdown-body a { + color: #58a6ff; + } + + .markdown-body hr { + background-color: #30363d; + } +} + +/* Task list styling */ +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item input[type='checkbox'] { + margin: 0 0.5em 0.25em -1.6em; + vertical-align: middle; +} + +/* Ensure compatibility with your app's theme */ +.markdown-body { + background-color: transparent !important; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, + Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji' !important; +}