Initial project setup: Obsidian intelligent journal organizer
- Add core agent architecture with Command + Skill pattern - Implement Claude API integration for content analysis - Add Obsidian REST API integration for vault operations - Create conversational interface (v2.0) with natural language processing - Add comprehensive configuration management and validation - Include project documentation and developer guides - Set up testing framework with unit, integration, and property tests - Add Kiro specs for Claude API configuration and code quality improvements - Configure project steering files for development guidelines
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user