Merge branch 'main' into upload-to-s3

This commit is contained in:
Wendong-Fan 2026-02-01 14:22:31 +00:00 committed by GitHub
commit 1cfe715af9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1139 additions and 278 deletions

View file

@ -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)}"
)

View file

@ -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)}

View file

@ -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",

View file

@ -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):

View file

@ -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

Before After
Before After

View file

@ -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>
</>
);
},
);

View file

@ -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);

View file

@ -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",
];

View file

@ -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

View 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;
}