Files
journal_organizer/skills/obsidian_skill.py
T

518 lines
19 KiB
Python
Raw Normal View History

"""
Obsidian 集成 Skill
负责与 Obsidian Local REST API 的交互
"""
import ssl
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Dict, Any, Optional, List, Union, AsyncGenerator
from ..agent_core import Skill, SkillType, SkillResult, CommandContext
from ..api_response_validation import validate_api_response
from ..dependency_manager import get_dependency_manager
from ..error_handling import (
APIError,
ConfigurationError,
ValidationError,
ErrorContext,
get_error_handler,
)
from ..input_validation import command_input_validator
# Try to import aiohttp with graceful degradation
dependency_manager = get_dependency_manager()
aiohttp = dependency_manager.get_module('aiohttp')
@asynccontextmanager
async def obsidian_api_client(
api_url: str, api_key: str
) -> AsyncGenerator[Any, None]:
"""
Async context manager for Obsidian API client
Args:
api_url: Obsidian API URL
api_key: API key for authentication
Yields:
Configured aiohttp ClientSession
Raises:
ConfigurationError: If aiohttp is not available
"""
if aiohttp is None:
raise ConfigurationError(
message="aiohttp library is not installed. Please install it with: pip install aiohttp>=3.9.0",
config_key="aiohttp_dependency",
)
# Create SSL context (skip certificate verification for local development)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Configure headers
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Create session with proper configuration
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with aiohttp.ClientSession(connector=connector, headers=headers) as session:
try:
yield session
except Exception as e:
# Log error but let it propagate
import logging
logger = logging.getLogger("obsidian_api_client")
logger.error(f"Error in Obsidian API client: {str(e)}")
raise
class ObsidianReadSkill(Skill):
"""读取 Obsidian 笔记 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_read",
skill_type=SkillType.READ,
description="从 Obsidian 读取笔记内容",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
读取 Obsidian 笔记
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- file_path: 笔记文件路径(相对于 vault)
- vault_path: vault 路径
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 包含笔记内容的结果
"""
try:
return await self._execute_read(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_read",
operation="read_note",
user_message="Failed to read note from Obsidian",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
# Handle any unexpected errors
api_error = APIError(
message=f"Unexpected error reading note: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_read",
operation="read_note",
user_message="An unexpected error occurred while reading the note",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_read(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual read operation"""
# Validate input parameters
validated_kwargs = command_input_validator.validate_skill_input(
'obsidian_read', kwargs
)
file_path: str = validated_kwargs["file_path"]
api_url: str = validated_kwargs.get("api_url", "https://localhost:27123")
api_key: str = validated_kwargs["api_key"]
# Use async context manager for API client
async with obsidian_api_client(api_url, api_key) as session:
# Build API URL
api_endpoint: str = f"{api_url}/vault/{file_path}"
self.logger.info(f"读取笔记: {file_path}")
async with session.get(api_endpoint) as response:
if response.status == 200:
content: str = await response.text()
# Validate API response
validated_response = validate_api_response(
content, "obsidian", "read"
)
return SkillResult(
success=True,
data={
"file_path": file_path,
"content": validated_response["content"],
"size": validated_response["length"],
"read_at": datetime.now().isoformat(),
},
message=f"成功读取笔记: {file_path}",
)
elif response.status == 404:
raise APIError(
message=f"Note file not found: {file_path}",
api_name="obsidian",
status_code=response.status,
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to read note: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)
class ObsidianWriteSkill(Skill):
"""写入 Obsidian 笔记 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_write",
skill_type=SkillType.WRITE,
description="向 Obsidian 写入或更新笔记",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
写入或创建 Obsidian 笔记
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- file_path: 笔记文件路径(相对于 vault)
- content: 要写入的内容
- overwrite: 是否覆盖现有内容(默认 False)
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 执行结果
"""
try:
return await self._execute_write(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_write",
operation="write_note",
user_message="Failed to write note to Obsidian",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error writing note: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_write",
operation="write_note",
user_message="An unexpected error occurred while writing the note",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_write(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual write operation"""
# Validate input parameters
validated_kwargs = command_input_validator.validate_skill_input(
'obsidian_write', kwargs
)
file_path: str = validated_kwargs["file_path"]
content: str = validated_kwargs["content"]
overwrite: bool = validated_kwargs.get("overwrite", False)
api_url: str = validated_kwargs.get("api_url", "https://localhost:27123")
api_key: str = validated_kwargs["api_key"]
async with obsidian_api_client(api_url, api_key) as session:
api_endpoint: str = f"{api_url}/vault/{file_path}"
payload: Dict[str, Union[str, bool]] = {
"content": content,
"overwrite": overwrite,
}
self.logger.info(f"写入笔记: {file_path}")
async with session.post(api_endpoint, json=payload) as response:
if response.status in [200, 201]:
# Validate API response
response_text = await response.text()
validated_response = validate_api_response(
response_text, "obsidian", "write"
)
return SkillResult(
success=True,
data={
"file_path": file_path,
"size": len(content) if content else 0,
"written_at": datetime.now().isoformat(),
"response": validated_response,
},
message=f"成功写入笔记: {file_path}",
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to write note: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)
class ObsidianAppendSkill(Skill):
"""追加内容到 Obsidian 笔记 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_append",
skill_type=SkillType.WRITE,
description="向 Obsidian 笔记追加内容",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
向笔记追加内容
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- file_path: 笔记文件路径
- content: 要追加的内容
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 执行结果
"""
try:
return await self._execute_append(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_append",
operation="append_note",
user_message="Failed to append content to Obsidian note",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error appending to note: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_append",
operation="append_note",
user_message="An unexpected error occurred while appending to the note",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_append(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual append operation"""
file_path: Optional[str] = kwargs.get("file_path")
content: Optional[str] = kwargs.get("content")
api_url: str = kwargs.get("api_url", "https://localhost:27123")
api_key: Optional[str] = kwargs.get("api_key")
if not file_path:
raise ValidationError(
message="File path is required for appending to notes",
field_name="file_path",
validation_rule="non_empty",
)
if content is None:
raise ValidationError(
message="Content is required for appending to notes",
field_name="content",
validation_rule="not_none",
)
if not api_key:
raise ConfigurationError(
message="Obsidian API key is required", config_key="api_key"
)
async with obsidian_api_client(api_url, api_key) as session:
api_endpoint: str = f"{api_url}/vault/{file_path}"
payload: Dict[str, Union[str, bool]] = {"content": content, "append": True}
self.logger.info(f"追加内容到笔记: {file_path}")
async with session.post(api_endpoint, json=payload) as response:
if response.status in [200, 201]:
return SkillResult(
success=True,
data={
"file_path": file_path,
"appended_size": len(content) if content else 0,
"appended_at": datetime.now().isoformat(),
},
message=f"成功追加内容到笔记: {file_path}",
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to append to note: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)
class ObsidianListFilesSkill(Skill):
"""列出 Obsidian 文件 Skill"""
def __init__(self) -> None:
super().__init__(
name="obsidian_list_files",
skill_type=SkillType.READ,
description="列出 Obsidian vault 中的文件",
)
self.error_handler = get_error_handler()
async def execute(self, context: CommandContext, **kwargs: Any) -> SkillResult:
"""
列出指定文件夹中的文件
Args:
context: 命令执行上下文
**kwargs: 包含以下参数
- folder_path: 文件夹路径(可选)
- api_url: API URL
- api_key: API 密钥
Returns:
SkillResult: 包含文件列表的结果
"""
try:
return await self._execute_list(context, **kwargs)
except (ValidationError, ConfigurationError, APIError) as e:
error_context = ErrorContext(
component="obsidian_list_files",
operation="list_files",
user_message="Failed to list files from Obsidian vault",
technical_details=kwargs,
)
error_response = self.error_handler.handle_error(e, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
except Exception as e:
api_error = APIError(
message=f"Unexpected error listing files: {str(e)}",
api_name="obsidian",
cause=e,
)
error_context = ErrorContext(
component="obsidian_list_files",
operation="list_files",
user_message="An unexpected error occurred while listing files",
)
error_response = self.error_handler.handle_error(api_error, error_context)
return SkillResult(
success=error_response["success"],
error=error_response["error"],
message=error_response["message"],
)
async def _execute_list(
self, context: CommandContext, **kwargs: Any
) -> SkillResult:
"""Internal method that performs the actual list operation"""
folder_path: str = kwargs.get("folder_path", "")
api_url: str = kwargs.get("api_url", "https://localhost:27123")
api_key: Optional[str] = kwargs.get("api_key")
if not api_key:
raise ConfigurationError(
message="Obsidian API key is required", config_key="api_key"
)
async with obsidian_api_client(api_url, api_key) as session:
api_endpoint: str = f"{api_url}/vault/list"
params: Dict[str, str] = {}
if folder_path:
params["path"] = folder_path
self.logger.info(f"列出文件: {folder_path or 'root'}")
async with session.get(api_endpoint, params=params) as response:
if response.status == 200:
files: Union[List[Any], Dict[str, Any]] = await response.json()
return SkillResult(
success=True,
data={
"folder_path": folder_path,
"files": files,
"count": len(files) if isinstance(files, list) else 0,
},
message=f"成功列出文件",
)
else:
error_text: str = await response.text()
raise APIError(
message=f"Failed to list files: {error_text}",
api_name="obsidian",
status_code=response.status,
response_data=error_text,
)