Enhancement Suite: Anti-loop protection, Kokoro TTS integration, ComfyUI status monitoring, and auto-open image functionality
This commit is contained in:
0
build/lib/ollama_cli/__init__.py
Normal file
0
build/lib/ollama_cli/__init__.py
Normal file
0
build/lib/ollama_cli/agents/__init__.py
Normal file
0
build/lib/ollama_cli/agents/__init__.py
Normal file
0
build/lib/ollama_cli/core/__init__.py
Normal file
0
build/lib/ollama_cli/core/__init__.py
Normal file
57
build/lib/ollama_cli/core/config.py
Normal file
57
build/lib/ollama_cli/core/config.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
CONFIG_FILE = os.path.expanduser("~/.ollama-cli-config.json")
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"excluded_models": [],
|
||||||
|
"git": {
|
||||||
|
"remote_url": "",
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"url": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"auto_save_sessions": True,
|
||||||
|
"mcp_servers": {},
|
||||||
|
"ollama_url": "http://localhost:11434",
|
||||||
|
"comfy_url": "http://localhost:9000",
|
||||||
|
"piper": {
|
||||||
|
"path": "~/.ollama-cli/piper/piper",
|
||||||
|
"model": "~/.ollama-cli/piper/voice.onnx"
|
||||||
|
},
|
||||||
|
"ntfy": {
|
||||||
|
"enabled": False,
|
||||||
|
"topic": "",
|
||||||
|
"server": "https://ntfy.sh"
|
||||||
|
},
|
||||||
|
"default_model": "llama3.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Load config file"""
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
# Merge with defaults to ensure all keys exist
|
||||||
|
for k, v in DEFAULT_CONFIG.items():
|
||||||
|
if k not in config:
|
||||||
|
config[k] = v
|
||||||
|
return config
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
def save_config(config):
|
||||||
|
"""Save config file"""
|
||||||
|
with open(CONFIG_FILE, 'w') as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
|
||||||
|
def update_config(key, value):
|
||||||
|
"""Update a specific config key"""
|
||||||
|
config = load_config()
|
||||||
|
config[key] = value
|
||||||
|
save_config(config)
|
||||||
87
build/lib/ollama_cli/core/ollama.py
Normal file
87
build/lib/ollama_cli/core/ollama.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import List, Dict, Optional, Generator
|
||||||
|
|
||||||
|
class OllamaClient:
|
||||||
|
def __init__(self, base_url: str = "http://localhost:11434"):
|
||||||
|
# Strip trailing slashes and common API paths if user provided them in config
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
if self.base_url.endswith("/api/chat"):
|
||||||
|
self.base_url = self.base_url[:-9]
|
||||||
|
elif self.base_url.endswith("/api"):
|
||||||
|
self.base_url = self.base_url[:-4]
|
||||||
|
|
||||||
|
self.chat_url = f"{self.base_url}/api/chat"
|
||||||
|
self.list_url = f"{self.base_url}/api/tags"
|
||||||
|
|
||||||
|
def get_available_models(self) -> List[str]:
|
||||||
|
"""Get list of models available in Ollama"""
|
||||||
|
try:
|
||||||
|
# Try API first
|
||||||
|
response = requests.get(self.list_url, timeout=5)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
return [m['name'] for m in data.get('models', [])]
|
||||||
|
|
||||||
|
# Fallback to CLI
|
||||||
|
result = subprocess.run("ollama list", shell=True, capture_output=True, text=True, timeout=5)
|
||||||
|
models = []
|
||||||
|
for line in result.stdout.split('\n')[1:]: # Skip header
|
||||||
|
if line.strip():
|
||||||
|
models.append(line.split()[0])
|
||||||
|
return models
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def chat(self, messages: List[Dict], model: str, stream: bool = True) -> Generator[str, None, str]:
|
||||||
|
"""Call Ollama API with streaming"""
|
||||||
|
try:
|
||||||
|
response = requests.post(self.chat_url, json={
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": stream
|
||||||
|
}, stream=stream, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
full_response = ""
|
||||||
|
if stream:
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if line:
|
||||||
|
chunk = json.loads(line)
|
||||||
|
if "message" in chunk and "content" in chunk["message"]:
|
||||||
|
content = chunk["message"]["content"]
|
||||||
|
full_response += content
|
||||||
|
yield content
|
||||||
|
return full_response
|
||||||
|
else:
|
||||||
|
data = response.json()
|
||||||
|
return data.get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
raise ConnectionError(f"Cannot connect to Ollama at {self.base_url}. Make sure it's running.")
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error calling Ollama: {str(e)}")
|
||||||
|
|
||||||
|
def select_best_model(self, user_input: str, available_models: List[str], default_model: str) -> str:
|
||||||
|
"""Select best model based on task type"""
|
||||||
|
if not available_models:
|
||||||
|
return default_model
|
||||||
|
|
||||||
|
# Model preferences
|
||||||
|
code_models = ["qwen2.5-coder", "mistral-nemo", "codellama", "deepseek-coder"]
|
||||||
|
general_models = ["llama3.2", "llama3.1", "mistral"]
|
||||||
|
|
||||||
|
# Simple heuristic
|
||||||
|
code_keywords = ['code', 'script', 'program', 'function', 'debug', 'python', 'javascript']
|
||||||
|
is_code_task = any(k in user_input.lower() for k in code_keywords)
|
||||||
|
|
||||||
|
target_list = code_models if is_code_task else general_models
|
||||||
|
|
||||||
|
for target in target_list:
|
||||||
|
for available in available_models:
|
||||||
|
if target in available.lower():
|
||||||
|
return available
|
||||||
|
|
||||||
|
return available_models[0] if available_models else default_model
|
||||||
53
build/lib/ollama_cli/core/sessions.py
Normal file
53
build/lib/ollama_cli/core/sessions.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
SESSION_DIR = os.path.expanduser("~/.ollama-cli-sessions")
|
||||||
|
|
||||||
|
def save_session(messages: List[Dict], session_name: Optional[str] = None) -> str:
|
||||||
|
"""Save conversation session"""
|
||||||
|
os.makedirs(SESSION_DIR, exist_ok=True)
|
||||||
|
if not session_name:
|
||||||
|
session_name = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# Ensure filename is safe
|
||||||
|
safe_name = "".join([c for c in session_name if c.isalnum() or c in (' ', '.', '_', '-')]).strip()
|
||||||
|
session_file = os.path.join(SESSION_DIR, f"{safe_name}.json")
|
||||||
|
|
||||||
|
with open(session_file, 'w') as f:
|
||||||
|
json.dump({
|
||||||
|
"messages": messages,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"name": session_name
|
||||||
|
}, f, indent=2)
|
||||||
|
return session_file
|
||||||
|
|
||||||
|
def load_session(session_name: str) -> Optional[List[Dict]]:
|
||||||
|
"""Load conversation session"""
|
||||||
|
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
|
||||||
|
if not os.path.exists(session_file):
|
||||||
|
# Try without extension if name doesn't have it
|
||||||
|
if not session_name.endswith('.json'):
|
||||||
|
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
|
||||||
|
|
||||||
|
if os.path.exists(session_file):
|
||||||
|
with open(session_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data.get("messages", [])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_sessions() -> List[str]:
|
||||||
|
"""List available sessions"""
|
||||||
|
if not os.path.exists(SESSION_DIR):
|
||||||
|
return []
|
||||||
|
sessions = [f.replace('.json', '') for f in os.listdir(SESSION_DIR) if f.endswith('.json')]
|
||||||
|
return sorted(sessions, reverse=True)
|
||||||
|
|
||||||
|
def delete_session(session_name: str) -> bool:
|
||||||
|
"""Delete a session"""
|
||||||
|
session_file = os.path.join(SESSION_DIR, f"{session_name}.json")
|
||||||
|
if os.path.exists(session_file):
|
||||||
|
os.remove(session_file)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
0
build/lib/ollama_cli/integrations/__init__.py
Normal file
0
build/lib/ollama_cli/integrations/__init__.py
Normal file
75
build/lib/ollama_cli/integrations/lsp.py
Normal file
75
build/lib/ollama_cli/integrations/lsp.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
LSP_SERVERS = {}
|
||||||
|
|
||||||
|
def start_lsp_server(language: str, root_path: str = ".") -> str:
|
||||||
|
"""Start a language server"""
|
||||||
|
lsp_commands = {
|
||||||
|
"python": ["pyright-langserver", "--stdio"],
|
||||||
|
"javascript": ["typescript-language-server", "--stdio"],
|
||||||
|
"typescript": ["typescript-language-server", "--stdio"],
|
||||||
|
"rust": ["rust-analyzer"],
|
||||||
|
"go": ["gopls"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if language not in lsp_commands:
|
||||||
|
return f"No LSP server configured for {language}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
lsp_commands[language],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
cwd=os.path.expanduser(root_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize
|
||||||
|
init_request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"processId": os.getpid(),
|
||||||
|
"rootUri": f"file://{os.path.abspath(os.path.expanduser(root_path))}",
|
||||||
|
"capabilities": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg = json.dumps(init_request)
|
||||||
|
process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}")
|
||||||
|
process.stdin.flush()
|
||||||
|
|
||||||
|
LSP_SERVERS[language] = process
|
||||||
|
return f"Started LSP server for {language}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error starting LSP: {str(e)}"
|
||||||
|
|
||||||
|
def lsp_goto_definition(language: str, file_path: str, line: int, character: int) -> str:
|
||||||
|
"""Get definition location"""
|
||||||
|
if language not in LSP_SERVERS:
|
||||||
|
return "LSP server not started"
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = LSP_SERVERS[language]
|
||||||
|
request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "textDocument/definition",
|
||||||
|
"params": {
|
||||||
|
"textDocument": {"uri": f"file://{os.path.abspath(file_path)}"},
|
||||||
|
"position": {"line": line, "character": character}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg = json.dumps(request)
|
||||||
|
process.stdin.write(f"Content-Length: {len(msg)}\r\n\r\n{msg}")
|
||||||
|
process.stdin.flush()
|
||||||
|
|
||||||
|
return "Definition lookup sent (response parsing not implemented)"
|
||||||
|
except Exception as e:
|
||||||
|
return f"LSP error: {str(e)}"
|
||||||
103
build/lib/ollama_cli/integrations/mcp.py
Normal file
103
build/lib/ollama_cli/integrations/mcp.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
MCP_SERVERS = {}
|
||||||
|
MCP_TOOLS = {}
|
||||||
|
|
||||||
|
def connect_mcp_server(name: str, command: str, args: Optional[List[str]] = None) -> str:
|
||||||
|
"""Connect to an MCP server via stdio"""
|
||||||
|
try:
|
||||||
|
cmd = [command] + (args or [])
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send initialize request
|
||||||
|
init_request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "ollama-cli", "version": "3.0"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(json.dumps(init_request) + "\n")
|
||||||
|
process.stdin.flush()
|
||||||
|
|
||||||
|
# Read response
|
||||||
|
response = process.stdout.readline()
|
||||||
|
if response:
|
||||||
|
data = json.loads(response)
|
||||||
|
if "result" in data:
|
||||||
|
MCP_SERVERS[name] = process
|
||||||
|
|
||||||
|
# List tools
|
||||||
|
tools_request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"method": "tools/list"
|
||||||
|
}
|
||||||
|
process.stdin.write(json.dumps(tools_request) + "\n")
|
||||||
|
process.stdin.flush()
|
||||||
|
|
||||||
|
tools_response = process.stdout.readline()
|
||||||
|
if tools_response:
|
||||||
|
tools_data = json.loads(tools_response)
|
||||||
|
if "result" in tools_data and "tools" in tools_data["result"]:
|
||||||
|
for tool in tools_data["result"]["tools"]:
|
||||||
|
tool_name = f"mcp_{name}_{tool['name']}"
|
||||||
|
MCP_TOOLS[tool_name] = {
|
||||||
|
"server": name,
|
||||||
|
"name": tool["name"],
|
||||||
|
"description": tool.get("description", ""),
|
||||||
|
"schema": tool.get("inputSchema", {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return f"Connected to MCP server '{name}' with {len([t for t in MCP_TOOLS if t.startswith(f'mcp_{name}_')])} tools"
|
||||||
|
|
||||||
|
return f"Failed to initialize MCP server '{name}'"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error connecting to MCP server: {str(e)}"
|
||||||
|
|
||||||
|
def call_mcp_tool(server_name: str, tool_name: str, arguments: Dict[str, Any]) -> str:
|
||||||
|
"""Call a tool on an MCP server"""
|
||||||
|
try:
|
||||||
|
if server_name not in MCP_SERVERS:
|
||||||
|
return f"Error: MCP server '{server_name}' not connected"
|
||||||
|
|
||||||
|
process = MCP_SERVERS[server_name]
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 3,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdin.write(json.dumps(request) + "\n")
|
||||||
|
process.stdin.flush()
|
||||||
|
|
||||||
|
response = process.stdout.readline()
|
||||||
|
if response:
|
||||||
|
data = json.loads(response)
|
||||||
|
if "result" in data:
|
||||||
|
return json.dumps(data["result"], indent=2)
|
||||||
|
elif "error" in data:
|
||||||
|
return f"MCP Error: {data['error']}"
|
||||||
|
|
||||||
|
return "No response from MCP server"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error calling MCP tool: {str(e)}"
|
||||||
64
build/lib/ollama_cli/integrations/notify.py
Normal file
64
build/lib/ollama_cli/integrations/notify.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from typing import Callable, List, Dict
|
||||||
|
|
||||||
|
def send_notification(config: Dict, title: str, message: str, priority: str = "default"):
|
||||||
|
"""Send notification via ntfy.sh"""
|
||||||
|
ntfy_config = config.get("ntfy", {})
|
||||||
|
|
||||||
|
if not ntfy_config.get("enabled") or not ntfy_config.get("topic"):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = ntfy_config.get("server", "https://ntfy.sh")
|
||||||
|
topic = ntfy_config["topic"]
|
||||||
|
url = f"{server}/{topic}"
|
||||||
|
|
||||||
|
requests.post(url,
|
||||||
|
data=message.encode('utf-8'),
|
||||||
|
headers={
|
||||||
|
"Title": title,
|
||||||
|
"Priority": priority
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ntfy_listener(config: Dict, on_message: Callable[[str], str]):
|
||||||
|
"""Listen for ntfy notifications and process them"""
|
||||||
|
ntfy_config = config.get("ntfy", {})
|
||||||
|
|
||||||
|
if not ntfy_config.get("enabled") or not ntfy_config.get("topic"):
|
||||||
|
return
|
||||||
|
|
||||||
|
server = ntfy_config.get("server", "https://ntfy.sh")
|
||||||
|
topic = ntfy_config["topic"]
|
||||||
|
url = f"{server}/{topic}/json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, stream=True, timeout=None)
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if line:
|
||||||
|
data = json.loads(line)
|
||||||
|
if data.get("event") == "message":
|
||||||
|
title = data.get("title", "")
|
||||||
|
message = data.get("message", "")
|
||||||
|
|
||||||
|
# Skip messages sent by this script
|
||||||
|
if title in ["Response", "Ollama CLI", "File Created", "Command Complete"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process message
|
||||||
|
response_text = on_message(message)
|
||||||
|
|
||||||
|
# Send response back
|
||||||
|
send_notification(config, "Response", response_text[:1000])
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def start_notify_thread(config: Dict, on_message: Callable[[str], str]):
|
||||||
|
thread = threading.Thread(target=ntfy_listener, args=(config, on_message), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
433
build/lib/ollama_cli/main.py
Normal file
433
build/lib/ollama_cli/main.py
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
"""
|
||||||
|
Ollama CLI v3.0
|
||||||
|
License: MIT
|
||||||
|
Author: Gemini CLI
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(os.path.expanduser("~/.ollama-cli.log")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("ollama-cli")
|
||||||
|
|
||||||
|
# Add current directory to path
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from ollama_cli.core.config import load_config, save_config, update_config
|
||||||
|
from ollama_cli.core.ollama import OllamaClient
|
||||||
|
from ollama_cli.core.sessions import save_session, load_session, list_sessions
|
||||||
|
from ollama_cli.ui.formatter import console, print_markdown, print_error, print_status, StreamingDisplay
|
||||||
|
from ollama_cli.ui.repl import REPL
|
||||||
|
from ollama_cli.tools.base import registry
|
||||||
|
import ollama_cli.tools.filesystem
|
||||||
|
import ollama_cli.tools.system
|
||||||
|
import ollama_cli.tools.web
|
||||||
|
import ollama_cli.tools.git
|
||||||
|
import ollama_cli.tools.code
|
||||||
|
import ollama_cli.tools.knowledge
|
||||||
|
import ollama_cli.tools.memory
|
||||||
|
import ollama_cli.tools.execution
|
||||||
|
import ollama_cli.tools.media
|
||||||
|
from ollama_cli.integrations.mcp import connect_mcp_server, call_mcp_tool, MCP_TOOLS
|
||||||
|
from ollama_cli.integrations.lsp import start_lsp_server, lsp_goto_definition
|
||||||
|
from ollama_cli.integrations.notify import start_notify_thread, send_notification
|
||||||
|
|
||||||
|
VERSION = "3.0.0"
|
||||||
|
|
||||||
|
class OllamaCLI:
|
||||||
|
def __init__(self):
|
||||||
|
self.config = load_config()
|
||||||
|
self.client = OllamaClient(self.config.get("ollama_url"))
|
||||||
|
self.repl = REPL()
|
||||||
|
self.messages = []
|
||||||
|
self.available_models = []
|
||||||
|
self.current_model = self.config.get("default_model", "llama3.2")
|
||||||
|
self.system_prompt = self._build_system_prompt()
|
||||||
|
self.max_tool_iterations = 5
|
||||||
|
|
||||||
|
def _build_system_prompt(self) -> str:
|
||||||
|
base_prompt = """You are a highly capable AI assistant powered by Ollama.
|
||||||
|
You have access to a variety of tools to help you complete tasks.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
1. When you need to use a tool, you MUST use the EXACT format below.
|
||||||
|
2. Output ONLY the tool call. DO NOT explain what you are doing.
|
||||||
|
3. DO NOT repeat a tool call if you have already received a result for it.
|
||||||
|
4. If you have the answer, just give it. Do not use tools unless necessary.
|
||||||
|
|
||||||
|
<tool_call>
|
||||||
|
<tool_name>name_of_tool</tool_name>
|
||||||
|
<parameters>{"param": "value"}</parameters>
|
||||||
|
</tool_call>
|
||||||
|
|
||||||
|
You can call multiple tools in one response by repeating the block above."""
|
||||||
|
|
||||||
|
tool_desc = registry.generate_system_prompt_snippet()
|
||||||
|
|
||||||
|
# Add MCP tools if any
|
||||||
|
if MCP_TOOLS:
|
||||||
|
tool_desc += "\nMCP Tools:\n"
|
||||||
|
for name, info in MCP_TOOLS.items():
|
||||||
|
tool_desc += f"- {name}: {info['description']}\n"
|
||||||
|
|
||||||
|
return f"{base_prompt}\n\n{tool_desc}"
|
||||||
|
|
||||||
|
def reset_messages(self):
|
||||||
|
self.messages = [{"role": "system", "content": self.system_prompt}]
|
||||||
|
|
||||||
|
def handle_command(self, user_input: str) -> bool:
|
||||||
|
"""Handle slash commands."""
|
||||||
|
if not user_input.startswith('/'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
parts = user_input.split()
|
||||||
|
cmd = parts[0].lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if cmd == '/help':
|
||||||
|
help_text = """
|
||||||
|
[bold cyan]Ollama CLI v3.0 Help[/bold cyan]
|
||||||
|
|
||||||
|
[bold]General Commands:[/bold]
|
||||||
|
/help - Show this help message
|
||||||
|
/quit - Exit the CLI
|
||||||
|
/clear - Clear conversation history
|
||||||
|
|
||||||
|
[bold]Model & Sessions:[/bold]
|
||||||
|
/models - List available models
|
||||||
|
/model <name> - Switch current model
|
||||||
|
/sessions - List saved sessions
|
||||||
|
/save [name] - Save current session
|
||||||
|
/load <name> - Load a saved session
|
||||||
|
|
||||||
|
[bold]Core Agent Tools:[/bold]
|
||||||
|
[blue]Filesystem:[/blue] read_file, write_file, list_directory, grep_search, get_tree
|
||||||
|
[blue]Code & Python:[/blue] run_python, replace_text, code_analyze_file, run_linter
|
||||||
|
[blue]Web:[/blue] web_search, read_url
|
||||||
|
[blue]Media:[/blue] generate_image, speak_text
|
||||||
|
[blue]Knowledge:[/blue] kb_add, kb_search, remember_fact, recall_facts, clear_memory
|
||||||
|
|
||||||
|
[bold]Integrations:[/bold]
|
||||||
|
/mcp connect <name> <cmd> [args] - Connect MCP server
|
||||||
|
/lsp start <lang> [path] - Start LSP server
|
||||||
|
/notify setup <topic> - Setup ntfy.sh remote control
|
||||||
|
|
||||||
|
[bold]Configuration:[/bold]
|
||||||
|
/config ollama <url> - Set Ollama server URL (default: http://localhost:11434)
|
||||||
|
/config comfy <url> - Set ComfyUI server URL (default: http://127.0.0.1:8188)
|
||||||
|
/config piper_path <path> - Set path to Piper binary
|
||||||
|
/config piper_model <path> - Set path to Piper voice model
|
||||||
|
"""
|
||||||
|
console.print(help_text)
|
||||||
|
elif cmd == '/config':
|
||||||
|
if len(parts) == 1:
|
||||||
|
cfg = load_config()
|
||||||
|
config_display = f"""
|
||||||
|
[bold cyan]Current Configuration:[/bold cyan]
|
||||||
|
[blue]Ollama URL:[/blue] {cfg.get('ollama_url')}
|
||||||
|
[blue]ComfyUI URL:[/blue] {cfg.get('comfy_url')}
|
||||||
|
[blue]Image Model:[/blue] {cfg.get('image_model')}
|
||||||
|
[blue]Piper Path:[/blue] {cfg.get('piper', {}).get('path')}
|
||||||
|
[blue]Piper Model:[/blue] {cfg.get('piper', {}).get('model')}
|
||||||
|
[blue]Default Model:[/blue] {cfg.get('default_model')}
|
||||||
|
"""
|
||||||
|
console.print(config_display)
|
||||||
|
elif len(parts) > 2:
|
||||||
|
subcmd = parts[1].lower()
|
||||||
|
val = parts[2]
|
||||||
|
if subcmd == 'ollama':
|
||||||
|
update_config("ollama_url", val)
|
||||||
|
print_status(f"Ollama URL updated to: {val}")
|
||||||
|
self.client = OllamaClient(val)
|
||||||
|
elif subcmd == 'comfy':
|
||||||
|
update_config("comfy_url", val)
|
||||||
|
print_status(f"ComfyUI URL updated to: {val}")
|
||||||
|
elif subcmd == 'piper_path':
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["piper"]["path"] = val
|
||||||
|
save_config(cfg)
|
||||||
|
print_status(f"Piper binary path updated to: {val}")
|
||||||
|
elif subcmd == 'piper_model':
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["piper"]["model"] = val
|
||||||
|
save_config(cfg)
|
||||||
|
print_status(f"Piper model path updated to: {val}")
|
||||||
|
else:
|
||||||
|
print_error("Usage: /config <ollama|comfy|piper_path|piper_model> <value>")
|
||||||
|
else:
|
||||||
|
print_error("Usage: /config <ollama|comfy|piper_path|piper_model> <value>")
|
||||||
|
elif cmd == '/quit':
|
||||||
|
print_status("Goodbye!")
|
||||||
|
sys.exit(0)
|
||||||
|
elif cmd == '/clear':
|
||||||
|
self.reset_messages()
|
||||||
|
print_status("History cleared.")
|
||||||
|
elif cmd == '/models':
|
||||||
|
self.available_models = self.client.get_available_models()
|
||||||
|
print_status(f"Available models: {', '.join(self.available_models)}")
|
||||||
|
elif cmd == '/model':
|
||||||
|
if len(parts) > 1:
|
||||||
|
self.current_model = parts[1]
|
||||||
|
update_config("default_model", self.current_model)
|
||||||
|
print_status(f"Switched to model: {self.current_model}")
|
||||||
|
else:
|
||||||
|
print_status(f"Current model: {self.current_model}")
|
||||||
|
elif cmd == '/sessions':
|
||||||
|
sessions = list_sessions()
|
||||||
|
print_status(f"Recent sessions: {', '.join(sessions[:10])}")
|
||||||
|
elif cmd == '/save':
|
||||||
|
name = parts[1] if len(parts) > 1 else None
|
||||||
|
path = save_session(self.messages, name)
|
||||||
|
print_status(f"Session saved to {path}")
|
||||||
|
elif cmd == '/load':
|
||||||
|
if len(parts) > 1:
|
||||||
|
messages = load_session(parts[1])
|
||||||
|
if messages:
|
||||||
|
self.messages = messages
|
||||||
|
print_status(f"Session '{parts[1]}' loaded.")
|
||||||
|
else:
|
||||||
|
print_error(f"Session '{parts[1]}' not found.")
|
||||||
|
else:
|
||||||
|
print_error("Usage: /load <session_name>")
|
||||||
|
elif cmd == '/mcp':
|
||||||
|
if len(parts) > 3 and parts[1] == 'connect':
|
||||||
|
name = parts[2]
|
||||||
|
command = parts[3]
|
||||||
|
args = parts[4:] if len(parts) > 4 else None
|
||||||
|
result = connect_mcp_server(name, command, args)
|
||||||
|
print_status(result)
|
||||||
|
# Refresh system prompt with new tools
|
||||||
|
self.system_prompt = self._build_system_prompt()
|
||||||
|
self.reset_messages()
|
||||||
|
else:
|
||||||
|
print_error("Usage: /mcp connect <name> <command> [args...]")
|
||||||
|
elif cmd == '/lsp':
|
||||||
|
if len(parts) > 2 and parts[1] == 'start':
|
||||||
|
lang = parts[2]
|
||||||
|
path = parts[3] if len(parts) > 3 else "."
|
||||||
|
result = start_lsp_server(lang, path)
|
||||||
|
print_status(result)
|
||||||
|
else:
|
||||||
|
print_error("Usage: /lsp start <language> [path]")
|
||||||
|
elif cmd == '/notify':
|
||||||
|
if len(parts) > 2 and parts[1] == 'setup':
|
||||||
|
topic = parts[2]
|
||||||
|
self.config["ntfy"]["enabled"] = True
|
||||||
|
self.config["ntfy"]["topic"] = topic
|
||||||
|
save_config(self.config)
|
||||||
|
start_notify_thread(self.config, self.process_remote_message)
|
||||||
|
print_status(f"Notifications enabled for topic: {topic}")
|
||||||
|
else:
|
||||||
|
print_error("Usage: /notify setup <topic>")
|
||||||
|
else:
|
||||||
|
print_error(f"Unknown command: {cmd}")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Command error: {e}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process_remote_message(self, message: str) -> str:
|
||||||
|
"""Handle messages coming from ntfy.sh"""
|
||||||
|
temp_messages = self.messages + [{"role": "user", "content": message}]
|
||||||
|
try:
|
||||||
|
response = ""
|
||||||
|
for chunk in self.client.chat(temp_messages, self.current_model, stream=False):
|
||||||
|
response += chunk
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
console.print(f"[bold cyan]Ollama CLI v{VERSION}[/bold cyan]")
|
||||||
|
|
||||||
|
# Initial model detection
|
||||||
|
try:
|
||||||
|
self.available_models = self.client.get_available_models()
|
||||||
|
if not self.available_models:
|
||||||
|
print_error("No models found. Make sure Ollama is running.")
|
||||||
|
else:
|
||||||
|
if self.current_model not in self.available_models:
|
||||||
|
self.current_model = self.available_models[0]
|
||||||
|
print_status(f"Using model: [bold green]{self.current_model}[/bold green]")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Could not connect to Ollama: {e}")
|
||||||
|
|
||||||
|
self.reset_messages()
|
||||||
|
|
||||||
|
# Start notification listener if enabled
|
||||||
|
if self.config.get("ntfy", {}).get("enabled"):
|
||||||
|
start_notify_thread(self.config, self.process_remote_message)
|
||||||
|
print_status(f"Notification listener active on topic: {self.config['ntfy']['topic']}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
user_input = self.repl.get_input()
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self.handle_command(user_input):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.messages.append({"role": "user", "content": user_input})
|
||||||
|
self.process_chat_cycle()
|
||||||
|
|
||||||
|
def process_chat_cycle(self):
|
||||||
|
"""The main loop for chat and tool execution."""
|
||||||
|
executed_tool_calls = set()
|
||||||
|
for iteration in range(self.max_tool_iterations):
|
||||||
|
full_response = ""
|
||||||
|
try:
|
||||||
|
print_status(f"Thinking ([dim]{self.current_model}[/dim])...")
|
||||||
|
with StreamingDisplay() as display:
|
||||||
|
for chunk in self.client.chat(self.messages, self.current_model):
|
||||||
|
display.update(chunk)
|
||||||
|
full_response += chunk
|
||||||
|
|
||||||
|
self.messages.append({"role": "assistant", "content": full_response})
|
||||||
|
|
||||||
|
# Check for tool calls
|
||||||
|
tool_calls = self.parse_tool_calls(full_response)
|
||||||
|
if not tool_calls:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Execute tool calls
|
||||||
|
results = []
|
||||||
|
for name, params in tool_calls:
|
||||||
|
# Prevent identical tool call loops across the entire cycle
|
||||||
|
call_key = f"{name}:{json.dumps(params, sort_keys=True)}"
|
||||||
|
if call_key in executed_tool_calls:
|
||||||
|
print_status(f"Skipping duplicate tool call: [dim]{name}[/dim]")
|
||||||
|
continue
|
||||||
|
executed_tool_calls.add(call_key)
|
||||||
|
|
||||||
|
result = self.execute_tool(name, params)
|
||||||
|
# Show result to user
|
||||||
|
print_status(f"Result: [dim]{result}[/dim]")
|
||||||
|
results.append(f"Tool Result ({name}):\n{result}")
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
# If we had tool calls but they were all duplicates, we must stop
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add all results to conversation
|
||||||
|
self.messages.append({"role": "user", "content": "\n\n".join(results) + "\n\nTool execution complete. If the task is finished, provide a final response to the user. Do not repeat the tool call."})
|
||||||
|
|
||||||
|
# If we made it here, loop again to let the model process results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(str(e))
|
||||||
|
logger.exception("Chat cycle error")
|
||||||
|
break
|
||||||
|
|
||||||
|
def parse_tool_calls(self, text: str) -> List[tuple]:
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
# 1. Try to find <tool_call> blocks (handles unclosed tags)
|
||||||
|
tc_blocks = re.findall(r'<tool_call>(.*?)(?:</tool_call>|$)', text, re.DOTALL)
|
||||||
|
if tc_blocks:
|
||||||
|
for block in tc_blocks:
|
||||||
|
if not block.strip(): continue
|
||||||
|
# Try to find tool_name tag (handles unclosed)
|
||||||
|
name_match = re.search(r'<tool_name>(.*?)(?:</tool_name>|$)', block, re.DOTALL)
|
||||||
|
name = name_match.group(1).strip() if name_match else None
|
||||||
|
|
||||||
|
# Try to find parameters tag (handles unclosed)
|
||||||
|
params_match = re.search(r'<parameters>(.*?)(?:</parameters>|$)', block, re.DOTALL)
|
||||||
|
if name and params_match:
|
||||||
|
try:
|
||||||
|
params_str = params_match.group(1).strip()
|
||||||
|
# Auto-fix unclosed JSON if needed
|
||||||
|
if params_str.startswith('{') and not params_str.endswith('}'):
|
||||||
|
params_str += '}'
|
||||||
|
calls.append((name, json.loads(params_str)))
|
||||||
|
except:
|
||||||
|
# Last ditch: search for any JSON-like object in the parameters string
|
||||||
|
json_match = re.search(r'(\{.*\})', params_str, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
try: calls.append((name, json.loads(json_match.group(1))))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 2. If no <tool_call> blocks, try searching for tool-specific tags
|
||||||
|
if not calls:
|
||||||
|
for tool_name in registry.tools.keys():
|
||||||
|
# Handle <tool_name ... /> or <tool_name ... >
|
||||||
|
attr_pattern = fr'<{tool_name}\s+(.*?)[\s/]*>'
|
||||||
|
attr_matches = re.findall(attr_pattern, text, re.DOTALL)
|
||||||
|
for attr_str in attr_matches:
|
||||||
|
attrs = {}
|
||||||
|
kv_pairs = re.findall(r'(\w+)\s*=\s*["\'](.*?)["\']', attr_str)
|
||||||
|
for k, v in kv_pairs:
|
||||||
|
attrs[k] = v
|
||||||
|
if attrs:
|
||||||
|
calls.append((tool_name, attrs))
|
||||||
|
|
||||||
|
# Handle <tool_name> {json} </tool_name>
|
||||||
|
block_pattern = fr'<{tool_name}>(.*?)(?:</{tool_name}>|$)'
|
||||||
|
block_matches = re.findall(block_pattern, text, re.DOTALL)
|
||||||
|
for b in block_matches:
|
||||||
|
json_match = re.search(r'(\{.*\})', b, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
try: calls.append((tool_name, json.loads(json_match.group(1))))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 3. Final fallback: look for tool_name followed by JSON { ... }
|
||||||
|
if not calls:
|
||||||
|
for tool_name in registry.tools.keys():
|
||||||
|
json_pattern = fr'{tool_name}\s*({{.*?}})'
|
||||||
|
json_matches = re.findall(json_pattern, text, re.DOTALL)
|
||||||
|
for json_str in json_matches:
|
||||||
|
try:
|
||||||
|
calls.append((tool_name, json.loads(json_str.strip())))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def execute_tool(self, name: str, params: Dict) -> str:
|
||||||
|
"""Execute a tool (standard or MCP)."""
|
||||||
|
# Try MCP tools first
|
||||||
|
if name in MCP_TOOLS:
|
||||||
|
mcp_tool = MCP_TOOLS[name]
|
||||||
|
print_status(f"Executing MCP tool [bold blue]{mcp_tool['name']}[/bold blue]...")
|
||||||
|
return call_mcp_tool(mcp_tool["server"], mcp_tool["name"], params)
|
||||||
|
|
||||||
|
# Try standard tools
|
||||||
|
tool = registry.get_tool(name)
|
||||||
|
if tool:
|
||||||
|
print_status(f"Executing [bold blue]{name}[/bold blue]...")
|
||||||
|
try:
|
||||||
|
result = tool.execute(**params)
|
||||||
|
# Notification for successful file/system ops
|
||||||
|
if name == "write_file" and "Successfully" in str(result):
|
||||||
|
send_notification(self.config, "File Created", f"Path: {params.get('path')}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error executing tool: {e}"
|
||||||
|
|
||||||
|
return f"Unknown tool: {name}"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
cli = OllamaCLI()
|
||||||
|
cli.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print_status("Interrupted by user. Exiting.")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Fatal error: {e}")
|
||||||
|
logger.exception("Fatal error")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
build/lib/ollama_cli/tools/__init__.py
Normal file
0
build/lib/ollama_cli/tools/__init__.py
Normal file
55
build/lib/ollama_cli/tools/base.py
Normal file
55
build/lib/ollama_cli/tools/base.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from typing import Dict, Any, Callable, List, Optional
|
||||||
|
import json
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
class BaseTool:
|
||||||
|
def __init__(self, name: str, description: str, parameters: Dict[str, Any]):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.parameters = parameters
|
||||||
|
|
||||||
|
def execute(self, **kwargs) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self.tools: Dict[str, BaseTool] = {}
|
||||||
|
|
||||||
|
def register(self, tool: BaseTool):
|
||||||
|
self.tools[tool.name] = tool
|
||||||
|
|
||||||
|
def get_tool(self, name: str) -> Optional[BaseTool]:
|
||||||
|
return self.tools.get(name)
|
||||||
|
|
||||||
|
def list_tools(self) -> List[Dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": t.name,
|
||||||
|
"description": t.description,
|
||||||
|
"parameters": t.parameters
|
||||||
|
}
|
||||||
|
for t in self.tools.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_system_prompt_snippet(self) -> str:
|
||||||
|
snippet = "Available tools:\n"
|
||||||
|
for t in self.tools.values():
|
||||||
|
snippet += f"- {t.name}: {t.description}\n"
|
||||||
|
snippet += f" Parameters: {json.dumps(t.parameters)}\n"
|
||||||
|
return snippet
|
||||||
|
|
||||||
|
# Singleton registry
|
||||||
|
registry = ToolRegistry()
|
||||||
|
|
||||||
|
def tool(name: str, description: str, parameters: Dict[str, Any]):
|
||||||
|
"""Decorator to register a function as a tool"""
|
||||||
|
def decorator(func: Callable):
|
||||||
|
class DecoratedTool(BaseTool):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name, description, parameters)
|
||||||
|
def execute(self, **kwargs):
|
||||||
|
return func(**kwargs)
|
||||||
|
|
||||||
|
registry.register(DecoratedTool())
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
96
build/lib/ollama_cli/tools/code.py
Normal file
96
build/lib/ollama_cli/tools/code.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from .base import tool
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="code_analyze_file",
|
||||||
|
description="Analyze code structure and suggest improvements",
|
||||||
|
parameters={
|
||||||
|
"file_path": {"type": "string", "description": "Path to the file to analyze"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def code_analyze_file(file_path: str) -> str:
|
||||||
|
try:
|
||||||
|
with open(os.path.expanduser(file_path), 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
lines = content.split('\n')
|
||||||
|
functions = [l for l in lines if 'def ' in l]
|
||||||
|
classes = [l for l in lines if 'class ' in l]
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
if len(lines) > 500:
|
||||||
|
issues.append(f"Large file ({len(lines)} lines) - consider splitting")
|
||||||
|
if len(functions) > 30:
|
||||||
|
issues.append(f"Many functions ({len(functions)}) - consider refactoring")
|
||||||
|
|
||||||
|
return f"""File: {file_path}
|
||||||
|
Lines: {len(lines)} | Functions: {len(functions)} | Classes: {len(classes)}
|
||||||
|
|
||||||
|
Issues:
|
||||||
|
{chr(10).join(f'- {i}' for i in issues) if issues else '- None detected'}"""
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="generate_readme",
|
||||||
|
description="Generate a README.md for the current project",
|
||||||
|
parameters={
|
||||||
|
"path": {"type": "string", "description": "Project root path", "default": "."}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def generate_readme(path: str = ".") -> str:
|
||||||
|
try:
|
||||||
|
project_name = os.path.basename(os.path.abspath(path))
|
||||||
|
readme_content = f"# {project_name}\n\nGenerated by Ollama CLI."
|
||||||
|
readme_path = os.path.join(path, "README.md")
|
||||||
|
with open(readme_path, 'w') as f:
|
||||||
|
f.write(readme_content)
|
||||||
|
return f"Generated README.md at {readme_path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="replace_text",
|
||||||
|
description="Surgically replace text in a file",
|
||||||
|
parameters={
|
||||||
|
"path": {"type": "string", "description": "Path to the file"},
|
||||||
|
"old_text": {"type": "string", "description": "The exact text to find"},
|
||||||
|
"new_text": {"type": "string", "description": "The text to replace it with"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def replace_text(path: str, old_text: str, new_text: str) -> str:
|
||||||
|
try:
|
||||||
|
expanded_path = os.path.expanduser(path)
|
||||||
|
with open(expanded_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if old_text not in content:
|
||||||
|
return f"Error: The text to replace was not found in {path}"
|
||||||
|
|
||||||
|
new_content = content.replace(old_text, new_text, 1) # Replace first occurrence
|
||||||
|
with open(expanded_path, 'w') as f:
|
||||||
|
f.write(new_content)
|
||||||
|
return f"Successfully updated {path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="run_linter",
|
||||||
|
description="Run a linter on a file",
|
||||||
|
parameters={
|
||||||
|
"file_path": {"type": "string", "description": "Path to the file"},
|
||||||
|
"linter": {"type": "string", "description": "Linter to use (pylint|eslint|auto)", "default": "auto"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def run_linter(file_path: str, linter: str = "auto") -> str:
|
||||||
|
try:
|
||||||
|
file_path = os.path.expanduser(file_path)
|
||||||
|
if linter == "auto":
|
||||||
|
ext = os.path.splitext(file_path)[1]
|
||||||
|
linter = "pylint" if ext == ".py" else "eslint"
|
||||||
|
|
||||||
|
result = subprocess.run(f"{linter} {file_path}", shell=True, capture_output=True, text=True)
|
||||||
|
return result.stdout + result.stderr
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
36
build/lib/ollama_cli/tools/execution.py
Normal file
36
build/lib/ollama_cli/tools/execution.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from .base import tool
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="run_python",
|
||||||
|
description="Execute a Python script or snippet",
|
||||||
|
parameters={
|
||||||
|
"code": {"type": "string", "description": "Python code to execute"},
|
||||||
|
"args": {"type": "string", "description": "Optional arguments", "default": ""}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def run_python(code: str, args: str = "") -> str:
|
||||||
|
try:
|
||||||
|
# Create a temp file
|
||||||
|
temp_file = ".ollama-cli-exec.py"
|
||||||
|
with open(temp_file, "w") as f:
|
||||||
|
f.write(code)
|
||||||
|
|
||||||
|
cmd = f"{sys.executable} {temp_file} {args}"
|
||||||
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
|
||||||
|
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
if os.path.exists(temp_file):
|
||||||
|
os.remove(temp_file)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return f"Success:\n{output}" if output else "Success (no output)"
|
||||||
|
else:
|
||||||
|
return f"Error (Exit {result.returncode}):\n{output}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Execution Error: {str(e)}"
|
||||||
83
build/lib/ollama_cli/tools/filesystem.py
Normal file
83
build/lib/ollama_cli/tools/filesystem.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from .base import tool
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="read_file",
|
||||||
|
description="Read the contents of a file",
|
||||||
|
parameters={
|
||||||
|
"path": {"type": "string", "description": "Path to the file"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def read_file(path: str) -> str:
|
||||||
|
try:
|
||||||
|
with open(os.path.expanduser(path), 'r') as f:
|
||||||
|
return f.read()
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error reading file: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="write_file",
|
||||||
|
description="Write content to a file",
|
||||||
|
parameters={
|
||||||
|
"path": {"type": "string", "description": "Path to the file"},
|
||||||
|
"content": {"type": "string", "description": "Content to write"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def write_file(path: str, content: str) -> str:
|
||||||
|
try:
|
||||||
|
expanded_path = os.path.expanduser(path)
|
||||||
|
os.makedirs(os.path.dirname(expanded_path), exist_ok=True)
|
||||||
|
with open(expanded_path, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
return f"Successfully written to {path}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error writing file: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="list_directory",
|
||||||
|
description="List contents of a directory",
|
||||||
|
parameters={
|
||||||
|
"path": {"type": "string", "description": "Path to the directory", "default": "."}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def list_directory(path: str = ".") -> str:
|
||||||
|
try:
|
||||||
|
expanded = os.path.expanduser(path)
|
||||||
|
files = os.listdir(expanded)
|
||||||
|
return "\n".join(files) if files else "Directory is empty"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error listing directory: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="grep_search",
|
||||||
|
description="Search for a regular expression pattern in file contents",
|
||||||
|
parameters={
|
||||||
|
"pattern": {"type": "string", "description": "The regex pattern to search for"},
|
||||||
|
"path": {"type": "string", "description": "Directory to search in", "default": "."},
|
||||||
|
"include": {"type": "string", "description": "Glob pattern for files to include", "default": "*"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def grep_search(pattern: str, path: str = ".", include: str = "*") -> str:
|
||||||
|
try:
|
||||||
|
cmd = f'grep -rE --include="{include}" "{pattern}" {path}'
|
||||||
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=20)
|
||||||
|
return result.stdout if result.stdout else "No matches found."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="get_tree",
|
||||||
|
description="Show the directory structure as a tree",
|
||||||
|
parameters={
|
||||||
|
"path": {"type": "string", "description": "Root path", "default": "."},
|
||||||
|
"depth": {"type": "integer", "description": "Max depth", "default": 2}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get_tree(path: str = ".", depth: int = 2) -> str:
|
||||||
|
try:
|
||||||
|
cmd = f"find {path} -maxdepth {depth} -not -path '*/.*'"
|
||||||
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||||
|
return result.stdout
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
52
build/lib/ollama_cli/tools/git.py
Normal file
52
build/lib/ollama_cli/tools/git.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import subprocess
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
from .base import tool
|
||||||
|
from ..core.config import load_config
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="git_operation",
|
||||||
|
description="Perform git operations (status, diff, commit, push, pull, list)",
|
||||||
|
parameters={
|
||||||
|
"operation": {"type": "string", "description": "Operation to perform (status|diff|commit|push|pull|list)"},
|
||||||
|
"message": {"type": "string", "description": "Commit message", "default": "Auto commit"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def git_operation(operation: str, message: str = "Auto commit") -> str:
|
||||||
|
config = load_config()
|
||||||
|
git_config = config.get("git", {})
|
||||||
|
gitea_config = config.get("gitea", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
if operation == "status":
|
||||||
|
result = subprocess.run("git status", shell=True, capture_output=True, text=True)
|
||||||
|
elif operation == "diff":
|
||||||
|
result = subprocess.run("git diff", shell=True, capture_output=True, text=True)
|
||||||
|
elif operation == "commit":
|
||||||
|
result = subprocess.run(f'git add -A && git commit -m "{message}"',
|
||||||
|
shell=True, capture_output=True, text=True)
|
||||||
|
elif operation == "push":
|
||||||
|
result = subprocess.run("git push", shell=True, capture_output=True, text=True)
|
||||||
|
elif operation == "pull":
|
||||||
|
result = subprocess.run("git pull", shell=True, capture_output=True, text=True)
|
||||||
|
elif operation == "list":
|
||||||
|
if gitea_config.get("url") and gitea_config.get("token"):
|
||||||
|
gitea_url = gitea_config["url"].rstrip("/")
|
||||||
|
token = gitea_config["token"]
|
||||||
|
response = requests.get(
|
||||||
|
f"{gitea_url}/api/v1/user/repos",
|
||||||
|
headers={"Authorization": f"token {token}"},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
repos = response.json()
|
||||||
|
return "\n".join([f"- {r['full_name']} ({r['clone_url']})" for r in repos])
|
||||||
|
return f"Gitea API error: {response.status_code}"
|
||||||
|
result = subprocess.run("find . -maxdepth 2 -name '.git' -type d | sed 's|/.git||'",
|
||||||
|
shell=True, capture_output=True, text=True)
|
||||||
|
else:
|
||||||
|
return f"Unknown git operation: {operation}"
|
||||||
|
|
||||||
|
return result.stdout + result.stderr
|
||||||
|
except Exception as e:
|
||||||
|
return f"Git error: {str(e)}"
|
||||||
41
build/lib/ollama_cli/tools/knowledge.py
Normal file
41
build/lib/ollama_cli/tools/knowledge.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from .base import tool
|
||||||
|
|
||||||
|
KB_FILE = os.path.expanduser("~/.ollama-cli-kb.json")
|
||||||
|
|
||||||
|
def load_kb():
|
||||||
|
if os.path.exists(KB_FILE):
|
||||||
|
with open(KB_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_kb(kb):
|
||||||
|
with open(KB_FILE, 'w') as f:
|
||||||
|
json.dump(kb, f, indent=2)
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="kb_add",
|
||||||
|
description="Add content to the knowledge base",
|
||||||
|
parameters={
|
||||||
|
"key": {"type": "string", "description": "Search key"},
|
||||||
|
"content": {"type": "string", "description": "Information content"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def kb_add(key: str, content: str) -> str:
|
||||||
|
kb = load_kb()
|
||||||
|
kb[key] = content
|
||||||
|
save_kb(kb)
|
||||||
|
return f"Added '{key}' to knowledge base"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="kb_search",
|
||||||
|
description="Search the knowledge base",
|
||||||
|
parameters={
|
||||||
|
"query": {"type": "string", "description": "Search query"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def kb_search(query: str) -> str:
|
||||||
|
kb = load_kb()
|
||||||
|
results = [f"{k}: {v[:100]}..." for k, v in kb.items() if query.lower() in k.lower() or query.lower() in v.lower()]
|
||||||
|
return "\n".join(results) if results else "No matches found"
|
||||||
183
build/lib/ollama_cli/tools/media.py
Normal file
183
build/lib/ollama_cli/tools/media.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
from .base import tool
|
||||||
|
from ..core.config import load_config
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="generate_image",
|
||||||
|
description="Generate an image using the local Stable Diffusion workflow (ComfyUI)",
|
||||||
|
parameters={
|
||||||
|
"prompt": {"type": "string", "description": "The image description"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def generate_image(prompt: str) -> str:
|
||||||
|
"""Invokes the ComfyUI API directly with a predefined workflow"""
|
||||||
|
config = load_config()
|
||||||
|
comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/")
|
||||||
|
image_model = config.get("image_model", "dreamshaper_8_pruned.safetensors")
|
||||||
|
|
||||||
|
# Determine dimensions (SDXL prefers 1024x1024)
|
||||||
|
width, height = (512, 512)
|
||||||
|
if "xl" in image_model.lower():
|
||||||
|
width, height = (1024, 1024)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if ComfyUI is running
|
||||||
|
try:
|
||||||
|
requests.get(comfy_url, timeout=2)
|
||||||
|
except:
|
||||||
|
return f"Error: ComfyUI server ({comfy_url}) appears to be down. Please start it first or configure the URL with '/config comfy <url>'."
|
||||||
|
|
||||||
|
# Define the workflow directly
|
||||||
|
workflow = {
|
||||||
|
"1": {
|
||||||
|
"inputs": {"ckpt_name": image_model},
|
||||||
|
"class_type": "CheckpointLoaderSimple"
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"inputs": {
|
||||||
|
"text": f"masterpiece, best quality, highly detailed, {prompt}",
|
||||||
|
"clip": ["1", 1]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode"
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"inputs": {
|
||||||
|
"text": "blurry, low quality, distorted, watermark, nsfw",
|
||||||
|
"clip": ["1", 1]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode"
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||||
|
"class_type": "EmptyLatentImage"
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": random.randint(1, 1000000000), "steps": 20, "cfg": 7.0,
|
||||||
|
"sampler_name": "dpmpp_2m", "scheduler": "karras",
|
||||||
|
"denoise": 1.0, "model": ["1", 0],
|
||||||
|
"positive": ["2", 0], "negative": ["3", 0],
|
||||||
|
"latent_image": ["4", 0]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler"
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
|
||||||
|
"class_type": "VAEDecode"
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"inputs": {
|
||||||
|
"filename_prefix": "ollama_cli_",
|
||||||
|
"images": ["6", 0]
|
||||||
|
},
|
||||||
|
"class_type": "SaveImage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {"prompt": workflow}
|
||||||
|
response = requests.post(f"{comfy_url}/prompt", json=payload, timeout=10)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
prompt_id = data.get('prompt_id', 'unknown')
|
||||||
|
return f"Image generation queued successfully on {comfy_url}.\nPrompt ID: {prompt_id}\nCheck your ComfyUI output folder for 'ollama_cli_' prefixed files."
|
||||||
|
else:
|
||||||
|
return f"Error form ComfyUI: {response.status_code} - {response.text}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error generating image: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="speak_text",
|
||||||
|
description="Convert text to speech using Piper TTS (high quality)",
|
||||||
|
parameters={
|
||||||
|
"text": {"type": "string", "description": "Text to speak"},
|
||||||
|
"output_file": {"type": "string", "description": "Output filename", "default": "output.wav"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def speak_text(text: str, output_file: str = "output.wav") -> str:
|
||||||
|
"""Uses the best available TTS engine (Kokoro > Piper > System)"""
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
# 1. Try Kokoro (Ultra-Realistic)
|
||||||
|
kokoro_python = os.path.expanduser(config.get("kokoro_path", "~/.ollama-cli/kokoro/venv/bin/python"))
|
||||||
|
if os.path.exists(kokoro_python):
|
||||||
|
try:
|
||||||
|
# Inline script to run in Kokoro venv
|
||||||
|
kokoro_script = f"""
|
||||||
|
import sys
|
||||||
|
import soundfile as sf
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
from kokoro import KPipeline
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
print("Dependencies missing")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
text = sys.argv[1]
|
||||||
|
output_file = sys.argv[2]
|
||||||
|
|
||||||
|
# Initialize pipeline
|
||||||
|
pipeline = KPipeline(lang_code='a')
|
||||||
|
generator = pipeline(text, voice='af_bella', speed=1)
|
||||||
|
|
||||||
|
all_audio = []
|
||||||
|
for gs, ps, audio in generator:
|
||||||
|
if audio is not None:
|
||||||
|
all_audio.append(audio)
|
||||||
|
|
||||||
|
if all_audio:
|
||||||
|
import numpy as np
|
||||||
|
full_audio = np.concatenate(all_audio)
|
||||||
|
sf.write(output_file, full_audio, 24000)
|
||||||
|
print("Success")
|
||||||
|
else:
|
||||||
|
print("No audio generated")
|
||||||
|
sys.exit(1)
|
||||||
|
"""
|
||||||
|
# Run in venv
|
||||||
|
subprocess.run([kokoro_python, "-c", kokoro_script, text, output_file],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.run(["afplay", output_file])
|
||||||
|
return f"Audio saved to {output_file} and played (using Ultra-Realistic Kokoro TTS)"
|
||||||
|
except Exception as e:
|
||||||
|
# Continue to fallback
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Try Piper (High-Quality)
|
||||||
|
piper_config = config.get("piper", {})
|
||||||
|
piper_bin = os.path.expanduser(piper_config.get("path", "~/.ollama-cli/piper/piper"))
|
||||||
|
model_path = os.path.expanduser(piper_config.get("model", "~/.ollama-cli/piper/voice.onnx"))
|
||||||
|
|
||||||
|
if os.path.exists(piper_bin) and os.path.exists(model_path):
|
||||||
|
try:
|
||||||
|
# Piper reads from stdin and writes to file
|
||||||
|
cmd = f'echo "{text}" | {piper_bin} --model {model_path} --output_file {output_file}'
|
||||||
|
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
||||||
|
|
||||||
|
# Auto-play on macOS
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.run(["afplay", output_file])
|
||||||
|
|
||||||
|
return f"Audio saved to {output_file} and played (using high-quality Piper TTS)"
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Fallback to system 'say' command on macOS
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
try:
|
||||||
|
subprocess.run(["say", "-o", output_file, text], check=True)
|
||||||
|
subprocess.run(["afplay", output_file])
|
||||||
|
return f"Audio saved to {output_file} and played (using system 'say' fallback)"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error using 'say': {e}"
|
||||||
|
|
||||||
|
return "No compatible TTS engine found. Please run the installer."
|
||||||
59
build/lib/ollama_cli/tools/memory.py
Normal file
59
build/lib/ollama_cli/tools/memory.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from .base import tool
|
||||||
|
|
||||||
|
PROJECT_MEMORY_FILE = ".ollama-cli/memory.json"
|
||||||
|
|
||||||
|
def load_project_memory():
|
||||||
|
if os.path.exists(PROJECT_MEMORY_FILE):
|
||||||
|
try:
|
||||||
|
with open(PROJECT_MEMORY_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_project_memory(memory):
|
||||||
|
os.makedirs(os.path.dirname(PROJECT_MEMORY_FILE), exist_ok=True)
|
||||||
|
with open(PROJECT_MEMORY_FILE, 'w') as f:
|
||||||
|
json.dump(memory, f, indent=2)
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="remember_fact",
|
||||||
|
description="Store a key piece of information about the project",
|
||||||
|
parameters={
|
||||||
|
"fact": {"type": "string", "description": "The fact to remember (e.g., 'API key is in .env', 'Main entry point is src/index.ts')"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def remember_fact(fact: str) -> str:
|
||||||
|
memory = load_project_memory()
|
||||||
|
facts = memory.get("facts", [])
|
||||||
|
if fact not in facts:
|
||||||
|
facts.append(fact)
|
||||||
|
memory["facts"] = facts
|
||||||
|
save_project_memory(memory)
|
||||||
|
return f"Remembered: {fact}"
|
||||||
|
return "Fact already known."
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="recall_facts",
|
||||||
|
description="Recall all stored facts about the project",
|
||||||
|
parameters={}
|
||||||
|
)
|
||||||
|
def recall_facts() -> str:
|
||||||
|
memory = load_project_memory()
|
||||||
|
facts = memory.get("facts", [])
|
||||||
|
if not facts:
|
||||||
|
return "No project facts stored."
|
||||||
|
return "Project Facts:\n" + "\n".join(f"- {f}" for f in facts)
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="clear_memory",
|
||||||
|
description="Clear project memory",
|
||||||
|
parameters={}
|
||||||
|
)
|
||||||
|
def clear_memory() -> str:
|
||||||
|
if os.path.exists(PROJECT_MEMORY_FILE):
|
||||||
|
os.remove(PROJECT_MEMORY_FILE)
|
||||||
|
return "Project memory cleared."
|
||||||
|
return "Memory was already empty."
|
||||||
46
build/lib/ollama_cli/tools/system.py
Normal file
46
build/lib/ollama_cli/tools/system.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
from .base import tool
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="execute_bash",
|
||||||
|
description="Execute a bash command and return its output",
|
||||||
|
parameters={
|
||||||
|
"command": {"type": "string", "description": "The command to execute"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def execute_bash(command: str) -> str:
|
||||||
|
# Basic safety check
|
||||||
|
dangerous = ['rm -rf /', 'dd if=', 'mkfs', ':(){:|:&};:']
|
||||||
|
if any(d in command for d in dangerous):
|
||||||
|
return f"⚠️ DANGEROUS COMMAND BLOCKED: {command}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=os.getcwd(),
|
||||||
|
timeout=60
|
||||||
|
)
|
||||||
|
output = result.stdout + result.stderr
|
||||||
|
return f"Exit code: {result.returncode}\n{output}" if output else f"Exit code: {result.returncode} (no output)"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "Error: Command timed out after 60 seconds"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="get_env_info",
|
||||||
|
description="Get information about the current system environment",
|
||||||
|
parameters={}
|
||||||
|
)
|
||||||
|
def get_env_info() -> str:
|
||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
return f"""OS: {platform.system()} {platform.release()}
|
||||||
|
Python: {sys.version}
|
||||||
|
Shell: {os.environ.get('SHELL', 'unknown')}
|
||||||
|
User: {os.environ.get('USER', 'unknown')}
|
||||||
|
CWD: {os.getcwd()}"""
|
||||||
67
build/lib/ollama_cli/tools/web.py
Normal file
67
build/lib/ollama_cli/tools/web.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import requests
|
||||||
|
import urllib.parse
|
||||||
|
from .base import tool
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="web_search",
|
||||||
|
description="Search the web for information",
|
||||||
|
parameters={
|
||||||
|
"query": {"type": "string", "description": "The search query"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def web_search(query: str) -> str:
|
||||||
|
"""Enhanced web search with DuckDuckGo"""
|
||||||
|
try:
|
||||||
|
encoded = urllib.parse.quote(query)
|
||||||
|
# Use DuckDuckGo Instant Answer API
|
||||||
|
api_url = f"https://api.duckduckgo.com/?q={encoded}&format=json"
|
||||||
|
response = requests.get(api_url, timeout=10)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
if data.get("AbstractText"):
|
||||||
|
results.append(f"Summary: {data['AbstractText']}")
|
||||||
|
|
||||||
|
for topic in data.get("RelatedTopics", [])[:5]:
|
||||||
|
if isinstance(topic, dict) and "Text" in topic:
|
||||||
|
results.append(f"- {topic['Text']}")
|
||||||
|
|
||||||
|
return "\n".join(results) if results else "No direct results found. Try a different query."
|
||||||
|
except Exception as e:
|
||||||
|
return f"Search error: {str(e)}"
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="read_url",
|
||||||
|
description="Read and extract text content from a URL",
|
||||||
|
parameters={
|
||||||
|
"url": {"type": "string", "description": "The URL to read"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def read_url(url: str) -> str:
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
|
# Remove scripts and styles
|
||||||
|
for script in soup(["script", "style"]):
|
||||||
|
script.decompose()
|
||||||
|
|
||||||
|
text = soup.get_text()
|
||||||
|
|
||||||
|
# Clean up whitespace
|
||||||
|
lines = (line.strip() for line in text.splitlines())
|
||||||
|
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
|
||||||
|
text = '\n'.join(chunk for chunk in chunks if chunk)
|
||||||
|
|
||||||
|
return text[:10000] + ("\n... (truncated)" if len(text) > 10000 else "")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Fallback if bs4 is missing (though we checked)
|
||||||
|
return response.text[:10000]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error reading URL: {str(e)}"
|
||||||
0
build/lib/ollama_cli/ui/__init__.py
Normal file
0
build/lib/ollama_cli/ui/__init__.py
Normal file
40
build/lib/ollama_cli/ui/formatter.py
Normal file
40
build/lib/ollama_cli/ui/formatter.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from rich.console import Console
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.text import Text
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
def print_markdown(text: str):
|
||||||
|
console.print(Markdown(text))
|
||||||
|
|
||||||
|
def print_panel(text: str, title: Optional[str] = None, style: str = "blue"):
|
||||||
|
console.print(Panel(text, title=title, border_style=style))
|
||||||
|
|
||||||
|
def print_error(text: str):
|
||||||
|
console.print(f"[bold red]Error:[/bold red] {text}")
|
||||||
|
|
||||||
|
def print_status(text: str):
|
||||||
|
console.print(f"[bold blue]>>>[/bold blue] {text}")
|
||||||
|
|
||||||
|
class StreamingDisplay:
|
||||||
|
def __init__(self, title: Optional[str] = None):
|
||||||
|
self.text = ""
|
||||||
|
self.live = Live(Text(self.text), refresh_per_second=10)
|
||||||
|
self.title = title
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.live.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.live.stop()
|
||||||
|
|
||||||
|
def update(self, chunk: str):
|
||||||
|
self.text += chunk
|
||||||
|
# Try to parse markdown if it looks complete-ish, otherwise just show text
|
||||||
|
# For performance, we might just show raw text during streaming
|
||||||
|
self.live.update(Markdown(self.text) if "```" in self.text else Text(self.text))
|
||||||
25
build/lib/ollama_cli/ui/repl.py
Normal file
25
build/lib/ollama_cli/ui/repl.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from prompt_toolkit import PromptSession
|
||||||
|
from prompt_toolkit.history import FileHistory
|
||||||
|
from prompt_toolkit.styles import Style
|
||||||
|
from prompt_toolkit.formatted_text import HTML
|
||||||
|
import os
|
||||||
|
|
||||||
|
class REPL:
|
||||||
|
def __init__(self):
|
||||||
|
history_file = os.path.expanduser("~/.ollama-cli-history")
|
||||||
|
self.session = PromptSession(history=FileHistory(history_file))
|
||||||
|
self.style = Style.from_dict({
|
||||||
|
'prompt': 'ansicyan bold',
|
||||||
|
'command': 'ansigreen',
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_input(self) -> str:
|
||||||
|
try:
|
||||||
|
return self.session.prompt(
|
||||||
|
HTML('<prompt><b>> </b></prompt>'),
|
||||||
|
style=self.style
|
||||||
|
).strip()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return ""
|
||||||
|
except EOFError:
|
||||||
|
return "/quit"
|
||||||
41
install.sh
41
install.sh
@@ -82,18 +82,51 @@ if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|||||||
|
|
||||||
# 5. Download Image Generation Model
|
# 5. Download Image Generation Model
|
||||||
echo ""
|
echo ""
|
||||||
echo "[*] Downloading Image Generation Model (DreamShaper 8)..."
|
echo "-------------------------------------------"
|
||||||
|
echo "Select Image Generation Model:"
|
||||||
|
echo "1) DreamShaper 8 (Good quality, fast, ~2GB)"
|
||||||
|
echo "2) Juggernaut XL (Photo-realistic, high detail, ~6GB)"
|
||||||
|
read -p "Choose option (1 or 2): " MODEL_CHOICE
|
||||||
|
echo ""
|
||||||
|
|
||||||
MODEL_DIR="$INSTALL_DIR/models/checkpoints"
|
MODEL_DIR="$INSTALL_DIR/models/checkpoints"
|
||||||
mkdir -p "$MODEL_DIR"
|
mkdir -p "$MODEL_DIR"
|
||||||
MODEL_PATH="$MODEL_DIR/dreamshaper_8_pruned.safetensors"
|
|
||||||
|
if [[ "$MODEL_CHOICE" == "2" ]]; then
|
||||||
|
MODEL_NAME="juggernaut_xl.safetensors"
|
||||||
|
MODEL_URL="https://huggingface.co/RunDiffusion/Juggernaut-XL-v9/resolve/main/Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors"
|
||||||
|
echo "[*] Downloading Juggernaut XL..."
|
||||||
|
else
|
||||||
|
MODEL_NAME="dreamshaper_8_pruned.safetensors"
|
||||||
|
MODEL_URL="https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors"
|
||||||
|
echo "[*] Downloading DreamShaper 8..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
MODEL_PATH="$MODEL_DIR/$MODEL_NAME"
|
||||||
if [ ! -f "$MODEL_PATH" ]; then
|
if [ ! -f "$MODEL_PATH" ]; then
|
||||||
echo " This may take a few minutes (approx. 2GB)..."
|
echo " This may take a few minutes..."
|
||||||
curl -L "https://huggingface.co/Lykon/DreamShaper/resolve/main/DreamShaper_8_pruned.safetensors" -o "$MODEL_PATH"
|
curl -L "$MODEL_URL" -o "$MODEL_PATH"
|
||||||
echo "✅ Model downloaded."
|
echo "✅ Model downloaded."
|
||||||
else
|
else
|
||||||
echo "✅ Model already exists."
|
echo "✅ Model already exists."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Update config with the chosen model name
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
config_path = os.path.expanduser('~/.ollama-cli-config.json')
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
else:
|
||||||
|
config = {}
|
||||||
|
config['image_model'] = '$MODEL_NAME'
|
||||||
|
config['comfy_output_path'] = os.path.expanduser('$INSTALL_DIR/output')
|
||||||
|
with open(config_path, 'w') as f:
|
||||||
|
json.dump(config, f, indent=2)
|
||||||
|
"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "[!] To use Image Generation, you must start ComfyUI:"
|
echo "[!] To use Image Generation, you must start ComfyUI:"
|
||||||
echo " cd $INSTALL_DIR"
|
echo " cd $INSTALL_DIR"
|
||||||
|
|||||||
BIN
output.wav
Normal file
BIN
output.wav
Normal file
Binary file not shown.
@@ -20,9 +20,12 @@ src/ollama_cli/integrations/notify.py
|
|||||||
src/ollama_cli/tools/__init__.py
|
src/ollama_cli/tools/__init__.py
|
||||||
src/ollama_cli/tools/base.py
|
src/ollama_cli/tools/base.py
|
||||||
src/ollama_cli/tools/code.py
|
src/ollama_cli/tools/code.py
|
||||||
|
src/ollama_cli/tools/execution.py
|
||||||
src/ollama_cli/tools/filesystem.py
|
src/ollama_cli/tools/filesystem.py
|
||||||
src/ollama_cli/tools/git.py
|
src/ollama_cli/tools/git.py
|
||||||
src/ollama_cli/tools/knowledge.py
|
src/ollama_cli/tools/knowledge.py
|
||||||
|
src/ollama_cli/tools/media.py
|
||||||
|
src/ollama_cli/tools/memory.py
|
||||||
src/ollama_cli/tools/system.py
|
src/ollama_cli/tools/system.py
|
||||||
src/ollama_cli/tools/web.py
|
src/ollama_cli/tools/web.py
|
||||||
src/ollama_cli/ui/__init__.py
|
src/ollama_cli/ui/__init__.py
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -18,6 +18,12 @@ DEFAULT_CONFIG = {
|
|||||||
"mcp_servers": {},
|
"mcp_servers": {},
|
||||||
"ollama_url": "http://localhost:11434",
|
"ollama_url": "http://localhost:11434",
|
||||||
"comfy_url": "http://localhost:9000",
|
"comfy_url": "http://localhost:9000",
|
||||||
|
"comfy_output_path": "~/ComfyUI/output",
|
||||||
|
"image_model": "dreamshaper_8_pruned.safetensors",
|
||||||
|
"piper": {
|
||||||
|
"path": "~/.ollama-cli/piper/piper",
|
||||||
|
"model": "~/.ollama-cli/piper/voice.onnx"
|
||||||
|
},
|
||||||
"ntfy": {
|
"ntfy": {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"topic": "",
|
"topic": "",
|
||||||
|
|||||||
@@ -60,13 +60,18 @@ class OllamaCLI:
|
|||||||
base_prompt = """You are a highly capable AI assistant powered by Ollama.
|
base_prompt = """You are a highly capable AI assistant powered by Ollama.
|
||||||
You have access to a variety of tools to help you complete tasks.
|
You have access to a variety of tools to help you complete tasks.
|
||||||
|
|
||||||
When you need to use a tool, output a tool call in the following format:
|
CRITICAL RULES:
|
||||||
|
1. When you need to use a tool, you MUST use the EXACT format below.
|
||||||
|
2. Output ONLY the tool call. DO NOT explain what you are doing.
|
||||||
|
3. DO NOT repeat a tool call if you have already received a result for it.
|
||||||
|
4. If you have the answer, just give it. Do not use tools unless necessary.
|
||||||
|
|
||||||
<tool_call>
|
<tool_call>
|
||||||
<tool_name>tool_name</tool_name>
|
<tool_name>name_of_tool</tool_name>
|
||||||
<parameters>{"param": "value"}</parameters>
|
<parameters>{"param": "value"}</parameters>
|
||||||
</tool_call>
|
</tool_call>
|
||||||
|
|
||||||
You can call multiple tools in one response. Be precise and helpful."""
|
You can call multiple tools in one response by repeating the block above."""
|
||||||
|
|
||||||
tool_desc = registry.generate_system_prompt_snippet()
|
tool_desc = registry.generate_system_prompt_snippet()
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ You can call multiple tools in one response. Be precise and helpful."""
|
|||||||
[blue]Filesystem:[/blue] read_file, write_file, list_directory, grep_search, get_tree
|
[blue]Filesystem:[/blue] read_file, write_file, list_directory, grep_search, get_tree
|
||||||
[blue]Code & Python:[/blue] run_python, replace_text, code_analyze_file, run_linter
|
[blue]Code & Python:[/blue] run_python, replace_text, code_analyze_file, run_linter
|
||||||
[blue]Web:[/blue] web_search, read_url
|
[blue]Web:[/blue] web_search, read_url
|
||||||
[blue]Media:[/blue] generate_image, speak_text
|
[blue]Media:[/blue] generate_image, speak_text, get_comfy_status
|
||||||
[blue]Knowledge:[/blue] kb_add, kb_search, remember_fact, recall_facts, clear_memory
|
[blue]Knowledge:[/blue] kb_add, kb_search, remember_fact, recall_facts, clear_memory
|
||||||
|
|
||||||
[bold]Integrations:[/bold]
|
[bold]Integrations:[/bold]
|
||||||
@@ -119,12 +124,28 @@ You can call multiple tools in one response. Be precise and helpful."""
|
|||||||
/notify setup <topic> - Setup ntfy.sh remote control
|
/notify setup <topic> - Setup ntfy.sh remote control
|
||||||
|
|
||||||
[bold]Configuration:[/bold]
|
[bold]Configuration:[/bold]
|
||||||
/config ollama <url> - Set Ollama server URL (default: http://localhost:11434)
|
/config ollama <url> - Set Ollama server URL
|
||||||
/config comfy <url> - Set ComfyUI server URL (default: http://127.0.0.1:8188)
|
/config comfy <url> - Set ComfyUI server URL
|
||||||
|
/config comfy_output <path> - Set ComfyUI output folder path
|
||||||
|
/config piper_path <path> - Set path to Piper binary
|
||||||
|
/config piper_model <path> - Set path to Piper voice model
|
||||||
"""
|
"""
|
||||||
console.print(help_text)
|
console.print(help_text)
|
||||||
elif cmd == '/config':
|
elif cmd == '/config':
|
||||||
if len(parts) > 2:
|
if len(parts) == 1:
|
||||||
|
cfg = load_config()
|
||||||
|
config_display = f"""
|
||||||
|
[bold cyan]Current Configuration:[/bold cyan]
|
||||||
|
[blue]Ollama URL:[/blue] {cfg.get('ollama_url')}
|
||||||
|
[blue]ComfyUI URL:[/blue] {cfg.get('comfy_url')}
|
||||||
|
[blue]Comfy Output:[/blue] {cfg.get('comfy_output_path')}
|
||||||
|
[blue]Image Model:[/blue] {cfg.get('image_model')}
|
||||||
|
[blue]Piper Path:[/blue] {cfg.get('piper', {}).get('path')}
|
||||||
|
[blue]Piper Model:[/blue] {cfg.get('piper', {}).get('model')}
|
||||||
|
[blue]Default Model:[/blue] {cfg.get('default_model')}
|
||||||
|
"""
|
||||||
|
console.print(config_display)
|
||||||
|
elif len(parts) > 2:
|
||||||
subcmd = parts[1].lower()
|
subcmd = parts[1].lower()
|
||||||
val = parts[2]
|
val = parts[2]
|
||||||
if subcmd == 'ollama':
|
if subcmd == 'ollama':
|
||||||
@@ -134,10 +155,23 @@ You can call multiple tools in one response. Be precise and helpful."""
|
|||||||
elif subcmd == 'comfy':
|
elif subcmd == 'comfy':
|
||||||
update_config("comfy_url", val)
|
update_config("comfy_url", val)
|
||||||
print_status(f"ComfyUI URL updated to: {val}")
|
print_status(f"ComfyUI URL updated to: {val}")
|
||||||
|
elif subcmd == 'comfy_output':
|
||||||
|
update_config("comfy_output_path", val)
|
||||||
|
print_status(f"ComfyUI output path updated to: {val}")
|
||||||
|
elif subcmd == 'piper_path':
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["piper"]["path"] = val
|
||||||
|
save_config(cfg)
|
||||||
|
print_status(f"Piper binary path updated to: {val}")
|
||||||
|
elif subcmd == 'piper_model':
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["piper"]["model"] = val
|
||||||
|
save_config(cfg)
|
||||||
|
print_status(f"Piper model path updated to: {val}")
|
||||||
else:
|
else:
|
||||||
print_error("Usage: /config <ollama|comfy> <url>")
|
print_error("Usage: /config <ollama|comfy|comfy_output|piper_path|piper_model> <value>")
|
||||||
else:
|
else:
|
||||||
print_error("Usage: /config <ollama|comfy> <url>")
|
print_error("Usage: /config <ollama|comfy|comfy_output|piper_path|piper_model> <value>")
|
||||||
elif cmd == '/quit':
|
elif cmd == '/quit':
|
||||||
print_status("Goodbye!")
|
print_status("Goodbye!")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -255,6 +289,7 @@ You can call multiple tools in one response. Be precise and helpful."""
|
|||||||
|
|
||||||
def process_chat_cycle(self):
|
def process_chat_cycle(self):
|
||||||
"""The main loop for chat and tool execution."""
|
"""The main loop for chat and tool execution."""
|
||||||
|
executed_tool_calls = set()
|
||||||
for iteration in range(self.max_tool_iterations):
|
for iteration in range(self.max_tool_iterations):
|
||||||
full_response = ""
|
full_response = ""
|
||||||
try:
|
try:
|
||||||
@@ -274,11 +309,24 @@ You can call multiple tools in one response. Be precise and helpful."""
|
|||||||
# Execute tool calls
|
# Execute tool calls
|
||||||
results = []
|
results = []
|
||||||
for name, params in tool_calls:
|
for name, params in tool_calls:
|
||||||
|
# Prevent identical tool call loops across the entire cycle
|
||||||
|
call_key = f"{name}:{json.dumps(params, sort_keys=True)}"
|
||||||
|
if call_key in executed_tool_calls:
|
||||||
|
print_status(f"Skipping duplicate tool call: [dim]{name}[/dim]")
|
||||||
|
continue
|
||||||
|
executed_tool_calls.add(call_key)
|
||||||
|
|
||||||
result = self.execute_tool(name, params)
|
result = self.execute_tool(name, params)
|
||||||
|
# Show result to user
|
||||||
|
print_status(f"Result: [dim]{result}[/dim]")
|
||||||
results.append(f"Tool Result ({name}):\n{result}")
|
results.append(f"Tool Result ({name}):\n{result}")
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
# If we had tool calls but they were all duplicates, we must stop
|
||||||
|
break
|
||||||
|
|
||||||
# Add all results to conversation
|
# Add all results to conversation
|
||||||
self.messages.append({"role": "user", "content": "\n\n".join(results)})
|
self.messages.append({"role": "user", "content": "\n\n".join(results) + "\n\nTool execution complete. If the task is finished, provide a final response to the user. Do not repeat the tool call."})
|
||||||
|
|
||||||
# If we made it here, loop again to let the model process results
|
# If we made it here, loop again to let the model process results
|
||||||
|
|
||||||
@@ -288,17 +336,67 @@ You can call multiple tools in one response. Be precise and helpful."""
|
|||||||
break
|
break
|
||||||
|
|
||||||
def parse_tool_calls(self, text: str) -> List[tuple]:
|
def parse_tool_calls(self, text: str) -> List[tuple]:
|
||||||
pattern = r'<tool_call>\s*<tool_name>(.*?)</tool_name>\s*<parameters>(.*?)</parameters>\s*</tool_call>'
|
|
||||||
matches = re.findall(pattern, text, re.DOTALL)
|
|
||||||
|
|
||||||
calls = []
|
calls = []
|
||||||
for name, params_str in matches:
|
|
||||||
name = name.strip()
|
# 1. Try to find <tool_call> blocks (handles unclosed tags)
|
||||||
try:
|
tc_blocks = re.findall(r'<tool_call>(.*?)(?:</tool_call>|$)', text, re.DOTALL)
|
||||||
params = json.loads(params_str.strip())
|
if tc_blocks:
|
||||||
calls.append((name, params))
|
for block in tc_blocks:
|
||||||
except Exception as e:
|
if not block.strip(): continue
|
||||||
print_error(f"Failed to parse parameters for {name}: {e}")
|
# Try to find tool_name tag (handles unclosed)
|
||||||
|
name_match = re.search(r'<tool_name>(.*?)(?:</tool_name>|$)', block, re.DOTALL)
|
||||||
|
name = name_match.group(1).strip() if name_match else None
|
||||||
|
|
||||||
|
# Try to find parameters tag (handles unclosed)
|
||||||
|
params_match = re.search(r'<parameters>(.*?)(?:</parameters>|$)', block, re.DOTALL)
|
||||||
|
if name and params_match:
|
||||||
|
try:
|
||||||
|
params_str = params_match.group(1).strip()
|
||||||
|
# Auto-fix unclosed JSON if needed
|
||||||
|
if params_str.startswith('{') and not params_str.endswith('}'):
|
||||||
|
params_str += '}'
|
||||||
|
calls.append((name, json.loads(params_str)))
|
||||||
|
except:
|
||||||
|
# Last ditch: search for any JSON-like object in the parameters string
|
||||||
|
json_match = re.search(r'(\{.*\})', params_str, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
try: calls.append((name, json.loads(json_match.group(1))))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 2. If no <tool_call> blocks, try searching for tool-specific tags
|
||||||
|
if not calls:
|
||||||
|
for tool_name in registry.tools.keys():
|
||||||
|
# Handle <tool_name ... /> or <tool_name ... >
|
||||||
|
attr_pattern = fr'<{tool_name}\s+(.*?)[\s/]*>'
|
||||||
|
attr_matches = re.findall(attr_pattern, text, re.DOTALL)
|
||||||
|
for attr_str in attr_matches:
|
||||||
|
attrs = {}
|
||||||
|
kv_pairs = re.findall(r'(\w+)\s*=\s*["\'](.*?)["\']', attr_str)
|
||||||
|
for k, v in kv_pairs:
|
||||||
|
attrs[k] = v
|
||||||
|
if attrs:
|
||||||
|
calls.append((tool_name, attrs))
|
||||||
|
|
||||||
|
# Handle <tool_name> {json} </tool_name>
|
||||||
|
block_pattern = fr'<{tool_name}>(.*?)(?:</{tool_name}>|$)'
|
||||||
|
block_matches = re.findall(block_pattern, text, re.DOTALL)
|
||||||
|
for b in block_matches:
|
||||||
|
json_match = re.search(r'(\{.*\})', b, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
try: calls.append((tool_name, json.loads(json_match.group(1))))
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
# 3. Final fallback: look for tool_name followed by JSON { ... }
|
||||||
|
if not calls:
|
||||||
|
for tool_name in registry.tools.keys():
|
||||||
|
json_pattern = fr'{tool_name}\s*({{.*?}})'
|
||||||
|
json_matches = re.findall(json_pattern, text, re.DOTALL)
|
||||||
|
for json_str in json_matches:
|
||||||
|
try:
|
||||||
|
calls.append((tool_name, json.loads(json_str.strip())))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
return calls
|
return calls
|
||||||
|
|
||||||
def execute_tool(self, name: str, params: Dict) -> str:
|
def execute_tool(self, name: str, params: Dict) -> str:
|
||||||
|
|||||||
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/execution.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/execution.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/media.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/media.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/ollama_cli/tools/__pycache__/memory.cpython-312.pyc
Normal file
BIN
src/ollama_cli/tools/__pycache__/memory.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,56 @@ import random
|
|||||||
from .base import tool
|
from .base import tool
|
||||||
from ..core.config import load_config
|
from ..core.config import load_config
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="generate_image",
|
||||||
|
description="Generate an image using the local Stable Diffusion workflow (ComfyUI)",
|
||||||
|
parameters={
|
||||||
|
"prompt": {"type": "string", "description": "The image description"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@tool(
|
||||||
|
name="get_comfy_status",
|
||||||
|
description="Check the status of image generation jobs or the ComfyUI queue",
|
||||||
|
parameters={
|
||||||
|
"prompt_id": {"type": "string", "description": "Optional: Specific prompt ID to check", "default": ""}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
def get_comfy_status(prompt_id: str = "") -> str:
|
||||||
|
"""Queries the ComfyUI API for queue or history status"""
|
||||||
|
config = load_config()
|
||||||
|
comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if prompt_id:
|
||||||
|
# Check history for specific prompt
|
||||||
|
resp = requests.get(f"{comfy_url}/history/{prompt_id}", timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
history = resp.json()
|
||||||
|
if prompt_id in history:
|
||||||
|
return f"Status for {prompt_id}: Completed. Result saved."
|
||||||
|
|
||||||
|
# Check queue if not in history
|
||||||
|
resp = requests.get(f"{comfy_url}/queue", timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
queue_data = resp.json()
|
||||||
|
for job in queue_data.get("queue_running", []):
|
||||||
|
if job[1] == prompt_id: return f"Status for {prompt_id}: Currently Running."
|
||||||
|
for job in queue_data.get("queue_pending", []):
|
||||||
|
if job[1] == prompt_id: return f"Status for {prompt_id}: Pending in Queue."
|
||||||
|
|
||||||
|
return f"Status for {prompt_id}: ID not found in current queue or history."
|
||||||
|
else:
|
||||||
|
# General queue status
|
||||||
|
resp = requests.get(f"{comfy_url}/queue", timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
queue_data = resp.json()
|
||||||
|
running = len(queue_data.get("queue_running", []))
|
||||||
|
pending = len(queue_data.get("queue_pending", []))
|
||||||
|
return f"ComfyUI Queue Status: {running} running, {pending} pending."
|
||||||
|
return f"Error: Could not retrieve queue from {comfy_url}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error connecting to ComfyUI: {str(e)}"
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="generate_image",
|
name="generate_image",
|
||||||
description="Generate an image using the local Stable Diffusion workflow (ComfyUI)",
|
description="Generate an image using the local Stable Diffusion workflow (ComfyUI)",
|
||||||
@@ -18,6 +68,12 @@ def generate_image(prompt: str) -> str:
|
|||||||
"""Invokes the ComfyUI API directly with a predefined workflow"""
|
"""Invokes the ComfyUI API directly with a predefined workflow"""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/")
|
comfy_url = config.get("comfy_url", "http://127.0.0.1:8188").rstrip("/")
|
||||||
|
image_model = config.get("image_model", "dreamshaper_8_pruned.safetensors")
|
||||||
|
|
||||||
|
# Determine dimensions (SDXL prefers 1024x1024)
|
||||||
|
width, height = (512, 512)
|
||||||
|
if "xl" in image_model.lower():
|
||||||
|
width, height = (1024, 1024)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if ComfyUI is running
|
# Check if ComfyUI is running
|
||||||
@@ -27,10 +83,9 @@ def generate_image(prompt: str) -> str:
|
|||||||
return f"Error: ComfyUI server ({comfy_url}) appears to be down. Please start it first or configure the URL with '/config comfy <url>'."
|
return f"Error: ComfyUI server ({comfy_url}) appears to be down. Please start it first or configure the URL with '/config comfy <url>'."
|
||||||
|
|
||||||
# Define the workflow directly
|
# Define the workflow directly
|
||||||
# Based on the user's previous generate_image.py, but using the configured URL
|
|
||||||
workflow = {
|
workflow = {
|
||||||
"1": {
|
"1": {
|
||||||
"inputs": {"ckpt_name": "dreamshaper_8_pruned.safetensors"},
|
"inputs": {"ckpt_name": image_model},
|
||||||
"class_type": "CheckpointLoaderSimple"
|
"class_type": "CheckpointLoaderSimple"
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
@@ -48,7 +103,7 @@ def generate_image(prompt: str) -> str:
|
|||||||
"class_type": "CLIPTextEncode"
|
"class_type": "CLIPTextEncode"
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
"inputs": {"width": width, "height": height, "batch_size": 1},
|
||||||
"class_type": "EmptyLatentImage"
|
"class_type": "EmptyLatentImage"
|
||||||
},
|
},
|
||||||
"5": {
|
"5": {
|
||||||
@@ -80,7 +135,44 @@ def generate_image(prompt: str) -> str:
|
|||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
prompt_id = data.get('prompt_id', 'unknown')
|
prompt_id = data.get('prompt_id', 'unknown')
|
||||||
return f"Image generation queued successfully on {comfy_url}.\nPrompt ID: {prompt_id}\nCheck your ComfyUI output folder for 'ollama_cli_' prefixed files."
|
|
||||||
|
# Start monitoring for the new image
|
||||||
|
output_path = os.path.expanduser(config.get("comfy_output_path", "~/ComfyUI/output"))
|
||||||
|
|
||||||
|
# Helper to find the newest file with our prefix
|
||||||
|
def find_newest_image():
|
||||||
|
import glob
|
||||||
|
files = glob.glob(os.path.join(output_path, "ollama_cli_*"))
|
||||||
|
if not files: return None
|
||||||
|
return max(files, key=os.path.getctime)
|
||||||
|
|
||||||
|
initial_newest = find_newest_image()
|
||||||
|
|
||||||
|
# Polling loop (max 60 seconds)
|
||||||
|
import time
|
||||||
|
found_file = None
|
||||||
|
for _ in range(30):
|
||||||
|
# Check status via API
|
||||||
|
status_check = get_comfy_status(prompt_id)
|
||||||
|
if "Completed" in status_check:
|
||||||
|
# Give filesystem a moment to sync
|
||||||
|
time.sleep(1)
|
||||||
|
current_newest = find_newest_image()
|
||||||
|
if current_newest and current_newest != initial_newest:
|
||||||
|
found_file = current_newest
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if found_file:
|
||||||
|
# Open the image automatically
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.run(["open", found_file])
|
||||||
|
else:
|
||||||
|
subprocess.run(["xdg-open", found_file])
|
||||||
|
return f"Image generation complete!\nSaved to: {found_file}\nOpening image now..."
|
||||||
|
|
||||||
|
return f"Image generation queued successfully on {comfy_url}.\nPrompt ID: {prompt_id}\nMonitoring timed out, but the image should appear in {output_path} soon."
|
||||||
else:
|
else:
|
||||||
return f"Error form ComfyUI: {response.status_code} - {response.text}"
|
return f"Error form ComfyUI: {response.status_code} - {response.text}"
|
||||||
|
|
||||||
@@ -96,26 +188,83 @@ def generate_image(prompt: str) -> str:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
def speak_text(text: str, output_file: str = "output.wav") -> str:
|
def speak_text(text: str, output_file: str = "output.wav") -> str:
|
||||||
"""Uses the high-quality Piper TTS installed by the installer"""
|
"""Uses the best available TTS engine (Kokoro > Piper > System)"""
|
||||||
piper_dir = os.path.expanduser("~/.ollama-cli/piper")
|
config = load_config()
|
||||||
piper_bin = os.path.join(piper_dir, "piper")
|
|
||||||
model_path = os.path.join(piper_dir, "voice.onnx")
|
# 1. Try Kokoro (Ultra-Realistic)
|
||||||
|
kokoro_python = os.path.expanduser(config.get("kokoro_path", "~/.ollama-cli/kokoro/venv/bin/python"))
|
||||||
|
if os.path.exists(kokoro_python):
|
||||||
|
try:
|
||||||
|
# Inline script to run in Kokoro venv
|
||||||
|
kokoro_script = f"""
|
||||||
|
import sys
|
||||||
|
import soundfile as sf
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
from kokoro import KPipeline
|
||||||
|
import torch
|
||||||
|
except ImportError:
|
||||||
|
print("Dependencies missing")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
text = sys.argv[1]
|
||||||
|
output_file = sys.argv[2]
|
||||||
|
|
||||||
|
# Initialize pipeline
|
||||||
|
pipeline = KPipeline(lang_code='a')
|
||||||
|
generator = pipeline(text, voice='af_bella', speed=1)
|
||||||
|
|
||||||
|
all_audio = []
|
||||||
|
for gs, ps, audio in generator:
|
||||||
|
if audio is not None:
|
||||||
|
all_audio.append(audio)
|
||||||
|
|
||||||
|
if all_audio:
|
||||||
|
import numpy as np
|
||||||
|
full_audio = np.concatenate(all_audio)
|
||||||
|
sf.write(output_file, full_audio, 24000)
|
||||||
|
print("Success")
|
||||||
|
else:
|
||||||
|
print("No audio generated")
|
||||||
|
sys.exit(1)
|
||||||
|
"""
|
||||||
|
# Run in venv
|
||||||
|
subprocess.run([kokoro_python, "-c", kokoro_script, text, output_file],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.run(["afplay", output_file])
|
||||||
|
return f"Audio saved to {output_file} and played (using Ultra-Realistic Kokoro TTS)"
|
||||||
|
except Exception as e:
|
||||||
|
# Continue to fallback
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Try Piper (High-Quality)
|
||||||
|
piper_config = config.get("piper", {})
|
||||||
|
piper_bin = os.path.expanduser(piper_config.get("path", "~/.ollama-cli/piper/piper"))
|
||||||
|
model_path = os.path.expanduser(piper_config.get("model", "~/.ollama-cli/piper/voice.onnx"))
|
||||||
|
|
||||||
if os.path.exists(piper_bin) and os.path.exists(model_path):
|
if os.path.exists(piper_bin) and os.path.exists(model_path):
|
||||||
try:
|
try:
|
||||||
# Piper reads from stdin and writes to file
|
# Piper reads from stdin and writes to file
|
||||||
cmd = f'echo "{text}" | {piper_bin} --model {model_path} --output_file {output_file}'
|
cmd = f'echo "{text}" | {piper_bin} --model {model_path} --output_file {output_file}'
|
||||||
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
subprocess.run(cmd, shell=True, check=True, capture_output=True)
|
||||||
return f"Audio saved to {output_file} (using high-quality Piper TTS)"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error using Piper: {e}. Falling back to system TTS..."
|
|
||||||
|
|
||||||
# Fallback to simple 'say' command on macOS
|
# Auto-play on macOS
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.run(["afplay", output_file])
|
||||||
|
|
||||||
|
return f"Audio saved to {output_file} and played (using high-quality Piper TTS)"
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Fallback to system 'say' command on macOS
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
try:
|
try:
|
||||||
subprocess.run(["say", "-o", output_file, text], check=True)
|
subprocess.run(["say", "-o", output_file, text], check=True)
|
||||||
return f"Audio saved to {output_file} (using system 'say' fallback)"
|
subprocess.run(["afplay", output_file])
|
||||||
|
return f"Audio saved to {output_file} and played (using system 'say' fallback)"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error using 'say': {e}"
|
return f"Error using 'say': {e}"
|
||||||
|
|
||||||
return "TTS engine not found. Please run the installer to set up Piper."
|
return "No compatible TTS engine found. Please run the installer."
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ def recall_facts() -> str:
|
|||||||
facts = memory.get("facts", [])
|
facts = memory.get("facts", [])
|
||||||
if not facts:
|
if not facts:
|
||||||
return "No project facts stored."
|
return "No project facts stored."
|
||||||
return "Project Facts:
|
return "Project Facts:\n" + "\n".join(f"- {f}" for f in facts)
|
||||||
" + "
|
|
||||||
".join(f"- {f}" for f in facts)
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="clear_memory",
|
name="clear_memory",
|
||||||
|
|||||||
Reference in New Issue
Block a user