- 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
518 lines
19 KiB
Python
518 lines
19 KiB
Python
"""
|
|
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,
|
|
)
|