mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-29 19:15:39 +00:00
Merge branch 'main' into upload-to-s3
This commit is contained in:
commit
1cfe715af9
11 changed files with 1139 additions and 278 deletions
|
|
@ -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)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 260 KiB |
|
|
@ -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<string | null>(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<string | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const lastContentRef = useRef<string | null>(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 (
|
||||
<div className="max-w-none markdown-container overflow-hidden">
|
||||
<pre className="bg-zinc-100 p-2 rounded text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all" style={{ wordBreak: 'break-all' }}>
|
||||
<code>{formattedHtml}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Convert markdown to HTML and process images
|
||||
useEffect(() => {
|
||||
const processMarkdown = async () => {
|
||||
if (!displayedContent) {
|
||||
setHtml('');
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-none markdown-container overflow-hidden">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-lg font-bold text-primary mb-2 break-words text-wrap">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-base font-semibold text-primary mb-2 break-words text-wrap">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-sm font-medium text-primary mb-1 break-words text-wrap">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p
|
||||
className={`${pTextSize} font-medium text-text-body leading-10 font-inter whitespace-pre-wrap break-all`}
|
||||
style={{ margin: 0, wordBreak: 'break-all' }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul
|
||||
className={`list-disc list-outside text-body-sm text-text-body ml-3 mb-2 ${olPadding}`}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="my-sm text-body-sm text-text-body">{children}</li>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code
|
||||
className="bg-zinc-100 px-1 py-0.5 rounded text-body-sm text-text-body font-mono whitespace-pre-wrap break-all"
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre
|
||||
className="bg-zinc-100 p-2 rounded text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all"
|
||||
style={{ wordBreak: 'break-all' }}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-zinc-300 pl-3 italic text-primary">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-primary">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-primary">{children}</em>
|
||||
),
|
||||
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 (
|
||||
<a
|
||||
href={cleanHref}
|
||||
className="text-blue-600 hover:text-blue-800 underline break-words inline"
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'break-word' }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{cleanChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto w-full max-w-full">
|
||||
<table
|
||||
className="w-full mb-4 !table min-w-0"
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
border: "1px solid #d1d5db",
|
||||
borderSpacing: 0,
|
||||
tableLayout: "auto",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead
|
||||
className="!table-header-group"
|
||||
style={{ backgroundColor: "#f9fafb" }}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
),
|
||||
tbody: ({ children }) => (
|
||||
<tbody className="!table-row-group">{children}</tbody>
|
||||
),
|
||||
tr: ({ children }) => <tr className="!table-row">{children}</tr>,
|
||||
th: ({ children }) => (
|
||||
<th
|
||||
className="text-left font-semibold text-primary text-[13px] !table-cell max-w-0"
|
||||
style={{
|
||||
border: "1px solid #d1d5db",
|
||||
padding: "8px 12px",
|
||||
borderCollapse: "collapse",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
maxWidth: "200px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td
|
||||
className="text-primary text-[13px] !table-cell max-w-0"
|
||||
style={{
|
||||
border: "1px solid #d1d5db",
|
||||
padding: "8px 12px",
|
||||
borderCollapse: "collapse",
|
||||
wordBreak: "break-word",
|
||||
overflowWrap: "break-word",
|
||||
maxWidth: "200px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{displayedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// If content is pure HTML, handle it separately
|
||||
if (isHtmlDocument(displayedContent)) {
|
||||
const formattedHtml = displayedContent
|
||||
.split('\n')
|
||||
.map((line) => line.trimStart())
|
||||
.join('\n')
|
||||
.trim();
|
||||
setHtml(
|
||||
`<pre class="bg-zinc-100 p-2 rounded text-xs font-mono overflow-x-auto whitespace-pre-wrap break-all" style="word-break: break-all;"><code>${DOMPurify.sanitize(formattedHtml)}</code></pre>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse markdown to HTML
|
||||
let rawHtml = await marked.parse(displayedContent);
|
||||
|
||||
// Process images: replace relative paths with data URLs
|
||||
if (contentBasePath) {
|
||||
const imgRegex = /<img([^>]*?)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 = `<img${beforeSrc}src="${dataUrl}"${afterSrc} class="cursor-pointer hover:opacity-90 transition-opacity" data-clickable="true" style="max-height: 320px; object-fit: contain;">`;
|
||||
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 = `<span class="inline-block text-sm text-zinc-500">[${alt}]</span>`;
|
||||
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(
|
||||
'<img',
|
||||
'<img class="cursor-pointer hover:opacity-90 transition-opacity" data-clickable="true" style="max-height: 320px; object-fit: contain;"',
|
||||
);
|
||||
rawHtml = rawHtml.replace(fullTag, newTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize HTML
|
||||
const sanitized = DOMPurify.sanitize(rawHtml);
|
||||
setHtml(sanitized);
|
||||
};
|
||||
|
||||
processMarkdown();
|
||||
}, [displayedContent, contentBasePath]);
|
||||
|
||||
// Add click handlers for images
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="markdown-body max-w-none overflow-hidden"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
|
||||
{/* Image preview dialog */}
|
||||
<Dialog open={!!previewImage} onOpenChange={() => setPreviewImage(null)}>
|
||||
<DialogContent
|
||||
size="lg"
|
||||
className="p-2 max-w-[95vw] max-h-[95vh] w-auto h-auto flex items-center justify-center"
|
||||
showCloseButton
|
||||
>
|
||||
{previewImage && (
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
className="max-w-full max-h-[90vh] w-auto h-auto object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Set<string>>(
|
||||
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 }) {
|
|||
<FileText className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -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 */}
|
||||
<div className={`flex-1 min-h-0 ${selectedFile?.type === 'html' && !isShowSourceCode ? 'overflow-hidden' : 'overflow-y-auto scrollbar'}`}>
|
||||
<div className={`h-full ${selectedFile?.type === 'html' && !isShowSourceCode ? '' : 'p-6'}`}>
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${selectedFile?.type === 'html' && !isShowSourceCode ? 'overflow-hidden' : 'overflow-y-auto scrollbar'}`}
|
||||
>
|
||||
<div
|
||||
className={`h-full ${selectedFile?.type === 'html' && !isShowSourceCode ? '' : 'p-6'}`}
|
||||
>
|
||||
{selectedFile ? (
|
||||
!loading ? (
|
||||
selectedFile.type === 'md' && !isShowSourceCode ? (
|
||||
|
|
@ -573,6 +580,11 @@ export default function Folder({ data }: { data?: Agent }) {
|
|||
<MarkDown
|
||||
content={selectedFile.content || ''}
|
||||
enableTypewriter={false}
|
||||
contentBasePath={
|
||||
selectedFile.isRemote
|
||||
? null
|
||||
: getDirPath(selectedFile.path)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : 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,
|
||||
) ? (
|
||||
<FolderComponent selectedFile={selectedFile} />
|
||||
) : 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 = /<script[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
const linkHrefRegex = /<link[^>]*href\s*=\s*["']([^"']+\.css)["'][^>]*>/gi;
|
||||
const linkHrefRegex =
|
||||
/<link[^>]*href\s*=\s*["']([^"']+\.css)["'][^>]*>/gi;
|
||||
|
||||
const referencedPaths: Set<string> = 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 = `<style data-source="${cssFile.name}">${cssContent}</style>`;
|
||||
|
||||
|
||||
// Try to replace the external link tag with inline style
|
||||
const linkRegex = new RegExp(
|
||||
`<link[^>]*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('<head>')) {
|
||||
processedHtmlContent = processedHtmlContent.replace(
|
||||
'<head>',
|
||||
`<head>${styleTag}`
|
||||
`<head>${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(
|
||||
`<script[^>]*src=["'](?:[^"']*[/\\\\])?${jsFile.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["'][^>]*>\\s*</script>`,
|
||||
'gi'
|
||||
'gi',
|
||||
);
|
||||
const inlineScriptTag = `<script data-source="${jsFile.name}">${jsContent}</script>`;
|
||||
processedHtmlContent = processedHtmlContent.replace(scriptRegex, inlineScriptTag);
|
||||
processedHtmlContent = processedHtmlContent.replace(
|
||||
scriptRegex,
|
||||
inlineScriptTag,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load JS file: ${jsFile.path}`, error);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
224
src/style/markdown-styles.css
Normal file
224
src/style/markdown-styles.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue