h2ogpte 1.6.41rc5__py3-none-any.whl → 1.6.43rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- h2ogpte/__init__.py +1 -1
- h2ogpte/cli/__init__.py +0 -0
- h2ogpte/cli/commands/__init__.py +0 -0
- h2ogpte/cli/commands/command_handlers/__init__.py +0 -0
- h2ogpte/cli/commands/command_handlers/agent.py +41 -0
- h2ogpte/cli/commands/command_handlers/chat.py +37 -0
- h2ogpte/cli/commands/command_handlers/clear.py +8 -0
- h2ogpte/cli/commands/command_handlers/collection.py +67 -0
- h2ogpte/cli/commands/command_handlers/config.py +113 -0
- h2ogpte/cli/commands/command_handlers/disconnect.py +36 -0
- h2ogpte/cli/commands/command_handlers/exit.py +37 -0
- h2ogpte/cli/commands/command_handlers/help.py +8 -0
- h2ogpte/cli/commands/command_handlers/history.py +29 -0
- h2ogpte/cli/commands/command_handlers/rag.py +146 -0
- h2ogpte/cli/commands/command_handlers/research_agent.py +45 -0
- h2ogpte/cli/commands/command_handlers/session.py +77 -0
- h2ogpte/cli/commands/command_handlers/status.py +33 -0
- h2ogpte/cli/commands/dispatcher.py +79 -0
- h2ogpte/cli/core/__init__.py +0 -0
- h2ogpte/cli/core/app.py +105 -0
- h2ogpte/cli/core/config.py +199 -0
- h2ogpte/cli/core/encryption.py +104 -0
- h2ogpte/cli/core/session.py +171 -0
- h2ogpte/cli/integrations/__init__.py +0 -0
- h2ogpte/cli/integrations/agent.py +338 -0
- h2ogpte/cli/integrations/rag.py +442 -0
- h2ogpte/cli/main.py +90 -0
- h2ogpte/cli/ui/__init__.py +0 -0
- h2ogpte/cli/ui/hbot_prompt.py +435 -0
- h2ogpte/cli/ui/prompts.py +129 -0
- h2ogpte/cli/ui/status_bar.py +133 -0
- h2ogpte/cli/utils/__init__.py +0 -0
- h2ogpte/cli/utils/file_manager.py +411 -0
- h2ogpte/h2ogpte.py +471 -67
- h2ogpte/h2ogpte_async.py +482 -68
- h2ogpte/h2ogpte_sync_base.py +8 -1
- h2ogpte/rest_async/__init__.py +6 -3
- h2ogpte/rest_async/api/chat_api.py +29 -0
- h2ogpte/rest_async/api/collections_api.py +293 -0
- h2ogpte/rest_async/api/extractors_api.py +2874 -70
- h2ogpte/rest_async/api/prompt_templates_api.py +32 -32
- h2ogpte/rest_async/api_client.py +1 -1
- h2ogpte/rest_async/configuration.py +1 -1
- h2ogpte/rest_async/models/__init__.py +5 -2
- h2ogpte/rest_async/models/chat_completion.py +4 -2
- h2ogpte/rest_async/models/chat_completion_delta.py +5 -3
- h2ogpte/rest_async/models/chat_completion_request.py +1 -1
- h2ogpte/rest_async/models/chat_session.py +4 -2
- h2ogpte/rest_async/models/chat_settings.py +1 -1
- h2ogpte/rest_async/models/collection.py +4 -2
- h2ogpte/rest_async/models/collection_create_request.py +4 -2
- h2ogpte/rest_async/models/create_chat_session_request.py +87 -0
- h2ogpte/rest_async/models/extraction_request.py +1 -1
- h2ogpte/rest_async/models/extractor.py +4 -2
- h2ogpte/rest_async/models/guardrails_settings.py +8 -4
- h2ogpte/rest_async/models/guardrails_settings_create_request.py +1 -1
- h2ogpte/rest_async/models/process_document_job_request.py +1 -1
- h2ogpte/rest_async/models/question_request.py +1 -1
- h2ogpte/rest_async/models/{reset_and_share_prompt_template_request.py → reset_and_share_request.py} +6 -6
- h2ogpte/{rest_sync/models/reset_and_share_prompt_template_with_groups_request.py → rest_async/models/reset_and_share_with_groups_request.py} +6 -6
- h2ogpte/rest_async/models/summarize_request.py +1 -1
- h2ogpte/rest_async/models/update_collection_workspace_request.py +87 -0
- h2ogpte/rest_async/models/update_extractor_privacy_request.py +87 -0
- h2ogpte/rest_sync/__init__.py +6 -3
- h2ogpte/rest_sync/api/chat_api.py +29 -0
- h2ogpte/rest_sync/api/collections_api.py +293 -0
- h2ogpte/rest_sync/api/extractors_api.py +2874 -70
- h2ogpte/rest_sync/api/prompt_templates_api.py +32 -32
- h2ogpte/rest_sync/api_client.py +1 -1
- h2ogpte/rest_sync/configuration.py +1 -1
- h2ogpte/rest_sync/models/__init__.py +5 -2
- h2ogpte/rest_sync/models/chat_completion.py +4 -2
- h2ogpte/rest_sync/models/chat_completion_delta.py +5 -3
- h2ogpte/rest_sync/models/chat_completion_request.py +1 -1
- h2ogpte/rest_sync/models/chat_session.py +4 -2
- h2ogpte/rest_sync/models/chat_settings.py +1 -1
- h2ogpte/rest_sync/models/collection.py +4 -2
- h2ogpte/rest_sync/models/collection_create_request.py +4 -2
- h2ogpte/rest_sync/models/create_chat_session_request.py +87 -0
- h2ogpte/rest_sync/models/extraction_request.py +1 -1
- h2ogpte/rest_sync/models/extractor.py +4 -2
- h2ogpte/rest_sync/models/guardrails_settings.py +8 -4
- h2ogpte/rest_sync/models/guardrails_settings_create_request.py +1 -1
- h2ogpte/rest_sync/models/process_document_job_request.py +1 -1
- h2ogpte/rest_sync/models/question_request.py +1 -1
- h2ogpte/rest_sync/models/{reset_and_share_prompt_template_request.py → reset_and_share_request.py} +6 -6
- h2ogpte/{rest_async/models/reset_and_share_prompt_template_with_groups_request.py → rest_sync/models/reset_and_share_with_groups_request.py} +6 -6
- h2ogpte/rest_sync/models/summarize_request.py +1 -1
- h2ogpte/rest_sync/models/update_collection_workspace_request.py +87 -0
- h2ogpte/rest_sync/models/update_extractor_privacy_request.py +87 -0
- h2ogpte/session.py +3 -2
- h2ogpte/session_async.py +22 -6
- h2ogpte/types.py +6 -0
- {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/METADATA +5 -1
- {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/RECORD +98 -59
- h2ogpte-1.6.43rc1.dist-info/entry_points.txt +2 -0
- {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/WHEEL +0 -0
- {h2ogpte-1.6.41rc5.dist-info → h2ogpte-1.6.43rc1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from rich.table import Table
|
|
2
|
+
|
|
3
|
+
from ...core.app import get_app_state
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def handle_status(args: str) -> bool:
|
|
7
|
+
"""Show session status."""
|
|
8
|
+
app = get_app_state()
|
|
9
|
+
|
|
10
|
+
# Show session status
|
|
11
|
+
app.session.display_status()
|
|
12
|
+
|
|
13
|
+
# Show connection status
|
|
14
|
+
status_table = Table(title="Connection Status", show_header=False)
|
|
15
|
+
status_table.add_column("Service", style="cyan")
|
|
16
|
+
status_table.add_column("Status", style="white")
|
|
17
|
+
|
|
18
|
+
rag_status = (
|
|
19
|
+
"[green]Connected[/green]"
|
|
20
|
+
if app.rag_manager.connected
|
|
21
|
+
else "[red]Disconnected[/red]"
|
|
22
|
+
)
|
|
23
|
+
agent_status = (
|
|
24
|
+
"[green]Connected[/green]"
|
|
25
|
+
if app.agent_manager.connected
|
|
26
|
+
else "[red]Disconnected[/red]"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
status_table.add_row("RAG System", rag_status)
|
|
30
|
+
status_table.add_row("Agent System", agent_status)
|
|
31
|
+
|
|
32
|
+
app.console.print(status_table)
|
|
33
|
+
return True
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from typing import Dict, Callable, Awaitable
|
|
2
|
+
|
|
3
|
+
from .command_handlers.help import handle_help
|
|
4
|
+
from .command_handlers.status import handle_status
|
|
5
|
+
from .command_handlers.clear import handle_clear
|
|
6
|
+
from .command_handlers.history import handle_history
|
|
7
|
+
from .command_handlers.exit import handle_exit
|
|
8
|
+
from .command_handlers.config import handle_config
|
|
9
|
+
from .command_handlers.rag import handle_register, handle_upload, handle_analyze
|
|
10
|
+
from .command_handlers.agent import handle_agent
|
|
11
|
+
from .command_handlers.chat import handle_chat
|
|
12
|
+
from .command_handlers.research_agent import handle_research_agent
|
|
13
|
+
from .command_handlers.session import handle_save, handle_load, handle_session
|
|
14
|
+
from .command_handlers.collection import handle_create_collection
|
|
15
|
+
from .command_handlers.disconnect import handle_disconnect
|
|
16
|
+
|
|
17
|
+
from ..core.app import get_app_state
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sanitize_command_for_history(command: str, cmd: str) -> str:
|
|
21
|
+
if cmd == "/register":
|
|
22
|
+
return "/register [hidden credentials]"
|
|
23
|
+
elif cmd in ["/config"] and "api" in command.lower():
|
|
24
|
+
return f"{cmd} [hidden sensitive data]"
|
|
25
|
+
else:
|
|
26
|
+
return command
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
COMMAND_HANDLERS: Dict[str, Callable[[str], Awaitable[bool]]] = {
|
|
30
|
+
"/help": handle_help,
|
|
31
|
+
"/status": handle_status,
|
|
32
|
+
"/clear": handle_clear,
|
|
33
|
+
"/history": handle_history,
|
|
34
|
+
"/exit": handle_exit,
|
|
35
|
+
"/quit": handle_exit,
|
|
36
|
+
"/config": handle_config,
|
|
37
|
+
"/register": handle_register,
|
|
38
|
+
"/upload": handle_upload,
|
|
39
|
+
"/analyze": handle_analyze,
|
|
40
|
+
"/agent": handle_agent,
|
|
41
|
+
"/research": handle_research_agent,
|
|
42
|
+
"/save": handle_save,
|
|
43
|
+
"/load": handle_load,
|
|
44
|
+
"/session": handle_session,
|
|
45
|
+
"/collection": handle_create_collection,
|
|
46
|
+
"/disconnect": handle_disconnect,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def dispatch_command(command: str) -> bool:
|
|
51
|
+
if not command:
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
app = get_app_state()
|
|
55
|
+
parts = command.split(maxsplit=1)
|
|
56
|
+
cmd = parts[0].lower()
|
|
57
|
+
args = parts[1] if len(parts) > 1 else ""
|
|
58
|
+
|
|
59
|
+
history_command = _sanitize_command_for_history(command, cmd)
|
|
60
|
+
app.session.add_to_history(history_command, None)
|
|
61
|
+
|
|
62
|
+
if cmd in COMMAND_HANDLERS:
|
|
63
|
+
try:
|
|
64
|
+
result = await COMMAND_HANDLERS[cmd](args)
|
|
65
|
+
return result if result is not None else True
|
|
66
|
+
except Exception as e:
|
|
67
|
+
app.ui.show_error(f"Command failed: {e}")
|
|
68
|
+
app.console.print(f"[dim]Debug: {type(e).__name__}: {e}[/dim]")
|
|
69
|
+
return True
|
|
70
|
+
else:
|
|
71
|
+
if command.startswith("/"):
|
|
72
|
+
app.ui.show_error(f"Unknown command: {cmd}")
|
|
73
|
+
app.ui.show_info("Type /help for available commands")
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
await handle_chat(command)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
app.ui.show_error(f"Chat failed: {e}")
|
|
79
|
+
return True
|
|
File without changes
|
h2ogpte/cli/core/app.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
|
|
4
|
+
from .config import settings
|
|
5
|
+
from .session import Session
|
|
6
|
+
from ..integrations.rag import RAGManager
|
|
7
|
+
from ..integrations.agent import AgentManager
|
|
8
|
+
from ..utils.file_manager import FileManager, FileUploader, DirectoryAnalyzer
|
|
9
|
+
from ..ui.prompts import UIManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppState:
|
|
13
|
+
"""Global application state container."""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.console = Console()
|
|
17
|
+
self.session = Session()
|
|
18
|
+
self.ui = UIManager()
|
|
19
|
+
self.settings = settings
|
|
20
|
+
self.rag_manager = RAGManager()
|
|
21
|
+
self.agent_manager = AgentManager()
|
|
22
|
+
self.file_manager = FileManager()
|
|
23
|
+
self.file_uploader = FileUploader(self.file_manager)
|
|
24
|
+
self.dir_analyzer = DirectoryAnalyzer(self.file_manager)
|
|
25
|
+
self._cleanup_done = False
|
|
26
|
+
|
|
27
|
+
# Register interrupt handler
|
|
28
|
+
self.session.register_interrupt_handler(self._handle_interrupt)
|
|
29
|
+
|
|
30
|
+
async def try_auto_reconnect(self):
|
|
31
|
+
"""Try to automatically reconnect using saved credentials."""
|
|
32
|
+
try:
|
|
33
|
+
if await self.rag_manager.auto_reconnect(self.settings):
|
|
34
|
+
await self.update_status_bar()
|
|
35
|
+
self.ui.show_success(
|
|
36
|
+
f"Reconnected as: {await self.rag_manager.get_username()}"
|
|
37
|
+
)
|
|
38
|
+
return True
|
|
39
|
+
except Exception as e:
|
|
40
|
+
# Silent failure - just continue without connection
|
|
41
|
+
pass
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
def _handle_interrupt(self):
|
|
45
|
+
"""Handle interrupt signal."""
|
|
46
|
+
if self.agent_manager.session:
|
|
47
|
+
self.agent_manager.session.interrupt()
|
|
48
|
+
|
|
49
|
+
async def update_status_bar(self):
|
|
50
|
+
"""Update the status bar with current connection info."""
|
|
51
|
+
# Check if we have connection info
|
|
52
|
+
connected = self.rag_manager.connected or self.agent_manager.connected
|
|
53
|
+
|
|
54
|
+
# Get actual username from RAG manager
|
|
55
|
+
username = None
|
|
56
|
+
collection = None
|
|
57
|
+
chat_session = None
|
|
58
|
+
if self.rag_manager.connected:
|
|
59
|
+
username = await self.rag_manager.get_username()
|
|
60
|
+
collection = await self.rag_manager.get_collection_name()
|
|
61
|
+
chat_session = await self.rag_manager.get_chat_session_name()
|
|
62
|
+
|
|
63
|
+
# Use RAG chat session if available, otherwise use manual session
|
|
64
|
+
session = chat_session or getattr(self, "_current_session", None)
|
|
65
|
+
|
|
66
|
+
self.ui.update_status(
|
|
67
|
+
connected=connected,
|
|
68
|
+
username=username,
|
|
69
|
+
collection=collection,
|
|
70
|
+
session=session,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def set_session(self, session_name: str):
|
|
74
|
+
"""Set the current session name."""
|
|
75
|
+
self._current_session = session_name
|
|
76
|
+
await self.update_status_bar()
|
|
77
|
+
|
|
78
|
+
async def cleanup(self):
|
|
79
|
+
"""Clean up all resources."""
|
|
80
|
+
if self._cleanup_done:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
await self.rag_manager.close()
|
|
84
|
+
await self.agent_manager.close()
|
|
85
|
+
self.session.save_session()
|
|
86
|
+
self._cleanup_done = True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Global app instance - initialized once
|
|
90
|
+
app_state: Optional[AppState] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_app_state() -> AppState:
|
|
94
|
+
"""Get the global app state instance."""
|
|
95
|
+
global app_state
|
|
96
|
+
if app_state is None:
|
|
97
|
+
app_state = AppState()
|
|
98
|
+
return app_state
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def initialize_app() -> AppState:
|
|
102
|
+
"""Initialize the global app state."""
|
|
103
|
+
global app_state
|
|
104
|
+
app_state = AppState()
|
|
105
|
+
return app_state
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import toml
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic_settings import BaseSettings
|
|
7
|
+
from pydantic_settings import SettingsConfigDict
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from .encryption import SecureStorage
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RAGConfig(BaseModel):
|
|
16
|
+
"""RAG system configuration."""
|
|
17
|
+
|
|
18
|
+
endpoint: str = Field(default="", description="RAG system endpoint URL")
|
|
19
|
+
api_key: str = Field(default="", description="API key for RAG system")
|
|
20
|
+
collection_name: str = Field(
|
|
21
|
+
default="default", description="Collection name in RAG"
|
|
22
|
+
)
|
|
23
|
+
chunk_size: int = Field(
|
|
24
|
+
default=1000, description="Chunk size for document processing"
|
|
25
|
+
)
|
|
26
|
+
chunk_overlap: int = Field(default=200, description="Overlap between chunks")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentConfig(BaseModel):
|
|
30
|
+
"""Agent system configuration."""
|
|
31
|
+
|
|
32
|
+
endpoint: str = Field(default="", description="Agent system endpoint URL")
|
|
33
|
+
api_key: str = Field(default="", description="API key for agent system")
|
|
34
|
+
model: str = Field(default="gpt-4", description="Model to use for agent")
|
|
35
|
+
temperature: float = Field(default=0.7, description="Temperature for generation")
|
|
36
|
+
max_tokens: int = Field(default=2000, description="Maximum tokens for response")
|
|
37
|
+
timeout: int = Field(default=300, description="Timeout in seconds")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UIConfig(BaseModel):
|
|
41
|
+
"""UI configuration."""
|
|
42
|
+
|
|
43
|
+
theme: str = Field(default="monokai", description="Syntax highlighting theme")
|
|
44
|
+
show_progress: bool = Field(default=True, description="Show progress bars")
|
|
45
|
+
auto_complete: bool = Field(default=True, description="Enable autocomplete")
|
|
46
|
+
history_size: int = Field(default=1000, description="Command history size")
|
|
47
|
+
animation: bool = Field(default=True, description="Enable animations")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Settings(BaseSettings):
|
|
51
|
+
"""Main application settings."""
|
|
52
|
+
|
|
53
|
+
app_name: str = "h2oGPTe-CLI"
|
|
54
|
+
debug: bool = False
|
|
55
|
+
|
|
56
|
+
config_dir: Path = Field(default_factory=lambda: Path.home() / ".h2ogpte-cli")
|
|
57
|
+
data_dir: Path = Field(
|
|
58
|
+
default_factory=lambda: Path.home() / ".h2ogpte-cli" / "data"
|
|
59
|
+
)
|
|
60
|
+
cache_dir: Path = Field(
|
|
61
|
+
default_factory=lambda: Path.home() / ".h2ogpte-cli" / "cache"
|
|
62
|
+
)
|
|
63
|
+
logs_dir: Path = Field(
|
|
64
|
+
default_factory=lambda: Path.home() / ".h2ogpte-cli" / "logs"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
rag: RAGConfig = Field(default_factory=RAGConfig)
|
|
68
|
+
agent: AgentConfig = Field(default_factory=AgentConfig)
|
|
69
|
+
ui: UIConfig = Field(default_factory=UIConfig)
|
|
70
|
+
|
|
71
|
+
_secure_storage: Optional[SecureStorage] = None
|
|
72
|
+
|
|
73
|
+
model_config = SettingsConfigDict(
|
|
74
|
+
env_file=(
|
|
75
|
+
".env",
|
|
76
|
+
".env.production",
|
|
77
|
+
".env.test",
|
|
78
|
+
".env.development",
|
|
79
|
+
".env.local",
|
|
80
|
+
),
|
|
81
|
+
env_prefix="H2OGPTE_CLI_",
|
|
82
|
+
env_file_encoding="utf-8",
|
|
83
|
+
env_nested_delimiter="__",
|
|
84
|
+
extra="ignore",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _get_secure_storage(self) -> SecureStorage:
|
|
88
|
+
"""Get or create secure storage instance."""
|
|
89
|
+
if self._secure_storage is None:
|
|
90
|
+
self._secure_storage = SecureStorage(self.config_dir)
|
|
91
|
+
return self._secure_storage
|
|
92
|
+
|
|
93
|
+
def get_rag_api_key(self) -> str:
|
|
94
|
+
"""Get decrypted RAG API key."""
|
|
95
|
+
storage = self._get_secure_storage()
|
|
96
|
+
if storage.is_encrypted(self.rag.api_key):
|
|
97
|
+
return storage.decrypt(self.rag.api_key)
|
|
98
|
+
return self.rag.api_key
|
|
99
|
+
|
|
100
|
+
def get_agent_api_key(self) -> str:
|
|
101
|
+
"""Get decrypted Agent API key."""
|
|
102
|
+
storage = self._get_secure_storage()
|
|
103
|
+
if storage.is_encrypted(self.agent.api_key):
|
|
104
|
+
return storage.decrypt(self.agent.api_key)
|
|
105
|
+
return self.agent.api_key
|
|
106
|
+
|
|
107
|
+
def set_rag_api_key(self, api_key: str):
|
|
108
|
+
"""Set encrypted RAG API key."""
|
|
109
|
+
storage = self._get_secure_storage()
|
|
110
|
+
self.rag.api_key = storage.encrypt(api_key)
|
|
111
|
+
|
|
112
|
+
def set_agent_api_key(self, api_key: str):
|
|
113
|
+
"""Set encrypted Agent API key."""
|
|
114
|
+
storage = self._get_secure_storage()
|
|
115
|
+
self.agent.api_key = storage.encrypt(api_key)
|
|
116
|
+
|
|
117
|
+
def save(self, path: Optional[Path] = None):
|
|
118
|
+
"""Save configuration to file."""
|
|
119
|
+
if path is None:
|
|
120
|
+
path = self.config_dir / "config.toml"
|
|
121
|
+
|
|
122
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
config_dict = {
|
|
125
|
+
"app": {
|
|
126
|
+
"name": self.app_name,
|
|
127
|
+
"debug": self.debug,
|
|
128
|
+
},
|
|
129
|
+
"directories": {
|
|
130
|
+
"config": str(self.config_dir),
|
|
131
|
+
"data": str(self.data_dir),
|
|
132
|
+
"cache": str(self.cache_dir),
|
|
133
|
+
"logs": str(self.logs_dir),
|
|
134
|
+
},
|
|
135
|
+
"rag": self.rag.model_dump(),
|
|
136
|
+
"agent": self.agent.model_dump(),
|
|
137
|
+
"ui": self.ui.model_dump(),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
with open(path, "w") as f:
|
|
141
|
+
toml.dump(config_dict, f)
|
|
142
|
+
|
|
143
|
+
console.print(f"[green]✓[/green] Configuration saved to {path}")
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def load(cls, path: Optional[Path] = None) -> "Settings":
|
|
147
|
+
"""Load configuration from file."""
|
|
148
|
+
if path is None:
|
|
149
|
+
path = Path.home() / ".h2ogpte-cli" / "config.toml"
|
|
150
|
+
|
|
151
|
+
if not path.exists():
|
|
152
|
+
return cls()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
with open(path, "r") as f:
|
|
156
|
+
config_dict = toml.load(f)
|
|
157
|
+
|
|
158
|
+
# Flatten the configuration
|
|
159
|
+
flat_config = {}
|
|
160
|
+
|
|
161
|
+
if "app" in config_dict:
|
|
162
|
+
app_config = config_dict["app"]
|
|
163
|
+
# Map config fields to Settings fields
|
|
164
|
+
if "name" in app_config:
|
|
165
|
+
flat_config["app_name"] = app_config["name"]
|
|
166
|
+
if "version" in app_config:
|
|
167
|
+
flat_config["version"] = app_config["version"]
|
|
168
|
+
if "debug" in app_config:
|
|
169
|
+
flat_config["debug"] = app_config["debug"]
|
|
170
|
+
|
|
171
|
+
if "directories" in config_dict:
|
|
172
|
+
dirs = config_dict["directories"]
|
|
173
|
+
for key in ["config_dir", "data_dir", "cache_dir", "logs_dir"]:
|
|
174
|
+
if key.replace("_dir", "") in dirs:
|
|
175
|
+
flat_config[key] = Path(dirs[key.replace("_dir", "")])
|
|
176
|
+
|
|
177
|
+
if "rag" in config_dict:
|
|
178
|
+
flat_config["rag"] = RAGConfig(**config_dict["rag"])
|
|
179
|
+
|
|
180
|
+
if "agent" in config_dict:
|
|
181
|
+
flat_config["agent"] = AgentConfig(**config_dict["agent"])
|
|
182
|
+
|
|
183
|
+
if "ui" in config_dict:
|
|
184
|
+
flat_config["ui"] = UIConfig(**config_dict["ui"])
|
|
185
|
+
|
|
186
|
+
return cls(**flat_config)
|
|
187
|
+
|
|
188
|
+
except Exception as e:
|
|
189
|
+
console.print(f"[yellow]⚠[/yellow] Error loading config: {e}")
|
|
190
|
+
return cls()
|
|
191
|
+
|
|
192
|
+
def ensure_directories(self):
|
|
193
|
+
"""Ensure all required directories exist."""
|
|
194
|
+
for dir_path in [self.config_dir, self.data_dir, self.cache_dir, self.logs_dir]:
|
|
195
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# Global settings instance
|
|
199
|
+
settings = Settings.load()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from cryptography.fernet import Fernet
|
|
7
|
+
from cryptography.hazmat.primitives import hashes
|
|
8
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecureStorage:
|
|
12
|
+
"""Handles encryption/decryption of sensitive data."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config_dir: Path):
|
|
15
|
+
self.config_dir = config_dir
|
|
16
|
+
self.key_file = config_dir / ".keyfile"
|
|
17
|
+
self._cipher_suite = None
|
|
18
|
+
|
|
19
|
+
def _get_machine_id(self) -> bytes:
|
|
20
|
+
"""Generate a machine-specific identifier."""
|
|
21
|
+
# Use machine-specific data for key generation
|
|
22
|
+
import platform
|
|
23
|
+
import socket
|
|
24
|
+
|
|
25
|
+
machine_data = (
|
|
26
|
+
platform.node()
|
|
27
|
+
+ platform.machine()
|
|
28
|
+
+ platform.processor()
|
|
29
|
+
+ str(os.getuid() if hasattr(os, "getuid") else "windows")
|
|
30
|
+
).encode()
|
|
31
|
+
|
|
32
|
+
return hashlib.sha256(machine_data).digest()
|
|
33
|
+
|
|
34
|
+
def _get_or_create_key(self) -> bytes:
|
|
35
|
+
"""Get or create encryption key."""
|
|
36
|
+
if self.key_file.exists():
|
|
37
|
+
with open(self.key_file, "rb") as f:
|
|
38
|
+
return f.read()
|
|
39
|
+
|
|
40
|
+
# Create new key based on machine ID
|
|
41
|
+
machine_id = self._get_machine_id()
|
|
42
|
+
|
|
43
|
+
# Use PBKDF2 to derive a key from machine ID
|
|
44
|
+
kdf = PBKDF2HMAC(
|
|
45
|
+
algorithm=hashes.SHA256(),
|
|
46
|
+
length=32,
|
|
47
|
+
salt=b"hbot_salt_2024", # Static salt for consistency
|
|
48
|
+
iterations=100000,
|
|
49
|
+
)
|
|
50
|
+
key = base64.urlsafe_b64encode(kdf.derive(machine_id))
|
|
51
|
+
|
|
52
|
+
# Save key to file
|
|
53
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with open(self.key_file, "wb") as f:
|
|
55
|
+
f.write(key)
|
|
56
|
+
|
|
57
|
+
# Set restrictive permissions
|
|
58
|
+
os.chmod(self.key_file, 0o600)
|
|
59
|
+
|
|
60
|
+
return key
|
|
61
|
+
|
|
62
|
+
def _get_cipher_suite(self) -> Fernet:
|
|
63
|
+
"""Get cipher suite for encryption/decryption."""
|
|
64
|
+
if self._cipher_suite is None:
|
|
65
|
+
key = self._get_or_create_key()
|
|
66
|
+
self._cipher_suite = Fernet(key)
|
|
67
|
+
return self._cipher_suite
|
|
68
|
+
|
|
69
|
+
def encrypt(self, data: str) -> str:
|
|
70
|
+
"""Encrypt sensitive data."""
|
|
71
|
+
if not data:
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
cipher_suite = self._get_cipher_suite()
|
|
75
|
+
encrypted_data = cipher_suite.encrypt(data.encode())
|
|
76
|
+
return base64.urlsafe_b64encode(encrypted_data).decode()
|
|
77
|
+
|
|
78
|
+
def decrypt(self, encrypted_data: str) -> str:
|
|
79
|
+
"""Decrypt sensitive data."""
|
|
80
|
+
if not encrypted_data:
|
|
81
|
+
return ""
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
cipher_suite = self._get_cipher_suite()
|
|
85
|
+
decoded_data = base64.urlsafe_b64decode(encrypted_data.encode())
|
|
86
|
+
decrypted_data = cipher_suite.decrypt(decoded_data)
|
|
87
|
+
return decrypted_data.decode()
|
|
88
|
+
except Exception:
|
|
89
|
+
# Return empty string if decryption fails (corrupted or wrong key)
|
|
90
|
+
return ""
|
|
91
|
+
|
|
92
|
+
def is_encrypted(self, data: str) -> bool:
|
|
93
|
+
"""Check if data appears to be encrypted."""
|
|
94
|
+
if not data:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
# Simple heuristic: encrypted data should be base64-like and long
|
|
98
|
+
try:
|
|
99
|
+
# Check if it's valid base64
|
|
100
|
+
base64.urlsafe_b64decode(data.encode())
|
|
101
|
+
# Encrypted data should be long (>50 chars) and not look like a normal API key
|
|
102
|
+
return len(data) > 50 and not data.startswith("sk-")
|
|
103
|
+
except:
|
|
104
|
+
return False
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import signal
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional, Dict, Any, List, Callable
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import json
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.layout import Layout
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Session:
|
|
20
|
+
"""Manages a CLI session with state, history, and interruption handling."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.start_time = datetime.now()
|
|
24
|
+
self.history: List[Dict[str, Any]] = []
|
|
25
|
+
self.context: Dict[str, Any] = {}
|
|
26
|
+
self.is_running = False
|
|
27
|
+
self.interrupted = False
|
|
28
|
+
self.current_task: Optional[asyncio.Task] = None
|
|
29
|
+
self.interrupt_handlers: List[Callable] = []
|
|
30
|
+
self.progress = Progress(
|
|
31
|
+
SpinnerColumn(),
|
|
32
|
+
TextColumn("[progress.description]{task.description}"),
|
|
33
|
+
BarColumn(),
|
|
34
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Setup interrupt handling
|
|
38
|
+
signal.signal(signal.SIGINT, self._handle_interrupt)
|
|
39
|
+
|
|
40
|
+
def _handle_interrupt(self, signum, frame):
|
|
41
|
+
"""Handle interrupt signal (Ctrl+C)."""
|
|
42
|
+
self.interrupted = True
|
|
43
|
+
console.print("\n[yellow]⚠[/yellow] Interrupt received. Processing...")
|
|
44
|
+
|
|
45
|
+
# Call registered interrupt handlers
|
|
46
|
+
for handler in self.interrupt_handlers:
|
|
47
|
+
try:
|
|
48
|
+
handler()
|
|
49
|
+
except Exception as e:
|
|
50
|
+
console.print(f"[red]Error in interrupt handler: {e}[/red]")
|
|
51
|
+
|
|
52
|
+
# Cancel current task if running
|
|
53
|
+
if self.current_task and not self.current_task.done():
|
|
54
|
+
self.current_task.cancel()
|
|
55
|
+
|
|
56
|
+
def register_interrupt_handler(self, handler: Callable):
|
|
57
|
+
"""Register a handler to be called on interrupt."""
|
|
58
|
+
self.interrupt_handlers.append(handler)
|
|
59
|
+
|
|
60
|
+
def add_to_history(self, command: str, result: Any, success: bool = True):
|
|
61
|
+
"""Add a command and its result to history."""
|
|
62
|
+
entry = {
|
|
63
|
+
"timestamp": datetime.now().isoformat(),
|
|
64
|
+
"command": command,
|
|
65
|
+
"result": str(result) if result else None,
|
|
66
|
+
"success": success,
|
|
67
|
+
}
|
|
68
|
+
self.history.append(entry)
|
|
69
|
+
|
|
70
|
+
def get_context(self, key: str, default: Any = None) -> Any:
|
|
71
|
+
"""Get a value from session context."""
|
|
72
|
+
return self.context.get(key, default)
|
|
73
|
+
|
|
74
|
+
def set_context(self, key: str, value: Any):
|
|
75
|
+
"""Set a value in session context."""
|
|
76
|
+
self.context[key] = value
|
|
77
|
+
|
|
78
|
+
def clear_context(self):
|
|
79
|
+
"""Clear session context."""
|
|
80
|
+
self.context.clear()
|
|
81
|
+
|
|
82
|
+
async def run_with_interrupt(self, coro, description: str = "Processing..."):
|
|
83
|
+
"""Run a coroutine with interrupt handling."""
|
|
84
|
+
self.is_running = True
|
|
85
|
+
self.interrupted = False
|
|
86
|
+
|
|
87
|
+
task_id = self.progress.add_task(description, total=None)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
with self.progress:
|
|
91
|
+
self.current_task = asyncio.create_task(coro)
|
|
92
|
+
result = await self.current_task
|
|
93
|
+
self.progress.update(task_id, completed=100)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
except asyncio.CancelledError:
|
|
97
|
+
console.print("[yellow]✗[/yellow] Task cancelled")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
console.print(f"[red]✗[/red] Error: {e}")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
finally:
|
|
105
|
+
self.is_running = False
|
|
106
|
+
self.current_task = None
|
|
107
|
+
self.progress.remove_task(task_id)
|
|
108
|
+
|
|
109
|
+
def save_session(self, path: Optional[Path] = None):
|
|
110
|
+
"""Save session to file."""
|
|
111
|
+
if path is None:
|
|
112
|
+
from .config import settings
|
|
113
|
+
|
|
114
|
+
path = (
|
|
115
|
+
settings.data_dir
|
|
116
|
+
/ f"session_{self.start_time.strftime('%Y%m%d_%H%M%S')}.json"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
session_data = {
|
|
122
|
+
"start_time": self.start_time.isoformat(),
|
|
123
|
+
"history": self.history,
|
|
124
|
+
"context": self.context,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
with open(path, "w") as f:
|
|
128
|
+
json.dump(session_data, f, indent=2, default=str)
|
|
129
|
+
|
|
130
|
+
# console.print(f"[green]✓[/green] Session saved to {path}")
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def load_session(cls, path: Path) -> "Session":
|
|
134
|
+
"""Load session from file."""
|
|
135
|
+
with open(path, "r") as f:
|
|
136
|
+
session_data = json.load(f)
|
|
137
|
+
|
|
138
|
+
session = cls()
|
|
139
|
+
session.start_time = datetime.fromisoformat(session_data["start_time"])
|
|
140
|
+
session.history = session_data["history"]
|
|
141
|
+
session.context = session_data["context"]
|
|
142
|
+
|
|
143
|
+
return session
|
|
144
|
+
|
|
145
|
+
def display_status(self):
|
|
146
|
+
"""Display current session status."""
|
|
147
|
+
layout = Layout()
|
|
148
|
+
|
|
149
|
+
# Session info
|
|
150
|
+
info_table = Table(title="Session Information", show_header=False)
|
|
151
|
+
info_table.add_row("Started", self.start_time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
152
|
+
info_table.add_row(
|
|
153
|
+
"Duration", str(datetime.now() - self.start_time).split(".")[0]
|
|
154
|
+
)
|
|
155
|
+
info_table.add_row("Commands", str(len(self.history)))
|
|
156
|
+
info_table.add_row("Context Items", str(len(self.context)))
|
|
157
|
+
|
|
158
|
+
# Recent history
|
|
159
|
+
history_table = Table(title="Recent History")
|
|
160
|
+
history_table.add_column("Time", style="dim")
|
|
161
|
+
history_table.add_column("Command")
|
|
162
|
+
history_table.add_column("Status")
|
|
163
|
+
|
|
164
|
+
for entry in self.history[-5:]:
|
|
165
|
+
time_str = datetime.fromisoformat(entry["timestamp"]).strftime("%H:%M:%S")
|
|
166
|
+
status = "[green]✓[/green]" if entry["success"] else "[red]✗[/red]"
|
|
167
|
+
history_table.add_row(time_str, entry["command"][:50], status)
|
|
168
|
+
|
|
169
|
+
layout.split_column(Layout(Panel(info_table)), Layout(Panel(history_table)))
|
|
170
|
+
|
|
171
|
+
console.print(layout)
|
|
File without changes
|