191 lines
5.6 KiB
Python
191 lines
5.6 KiB
Python
# deck.llm: a class with (online) LLM interactions for T-Deck
|
|
# Supports external config files and multiple providers
|
|
# Created by Luxferre in 2025, released into public domain
|
|
|
|
import json, re
|
|
try:
|
|
from deck.time import time
|
|
except:
|
|
import time
|
|
|
|
# init requests library in a platform-agnostic way
|
|
requests = None
|
|
try:
|
|
from deck import net
|
|
print('Waiting for online status...')
|
|
status, stext, myip = net.wifi_connect_wait()
|
|
if status:
|
|
print("We're online, initing requests lib...")
|
|
requests = net.init_requests()
|
|
except:
|
|
import requests
|
|
|
|
class LLMChat:
|
|
"""
|
|
Class for chat-like LLM interactions
|
|
"""
|
|
def __init__(self, config_file='llmcfg.json', message_limit=10):
|
|
self.config = self.load_config(config_file)
|
|
self.config_file = config_file
|
|
self.message_limit = message_limit
|
|
self.messages = []
|
|
self.setup_provider()
|
|
self.reset_context()
|
|
|
|
def load_config(self, config_file='llmcfg.json'):
|
|
"""
|
|
Loads config from JSON file with CircuitPython fallback.
|
|
"""
|
|
try:
|
|
with open(config_file, 'r') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
print(f"Error: Could not load {config_file} ({e})")
|
|
# Minimal fallback for embedded if file is missing
|
|
return {
|
|
"system_prompt": "Concise assistant",
|
|
"temperature": 0.7,
|
|
"active_provider": "local",
|
|
"providers": {"pollinations": {"base_url": "https://text.pollinations.ai/openai", "api_key": "", "model": "openai-fast"}}
|
|
}
|
|
|
|
def save_config(self):
|
|
"""
|
|
Saves config. Note: May fail on CircuitPython if USB is connected.
|
|
"""
|
|
try:
|
|
with open(self.config_file, 'w') as f:
|
|
json.dump(self.config, f)
|
|
return (True, "Configuration saved.")
|
|
except OSError as e:
|
|
return (False, f"Save failed: {e}. (Is filesystem read-only?)")
|
|
|
|
def setup_provider(self):
|
|
"""
|
|
Sets up headers and provider settings.
|
|
"""
|
|
active_name = self.config.get('active_provider')
|
|
providers = self.config.get('providers', {})
|
|
|
|
if active_name not in providers:
|
|
return (False, f"Provider {active_name} not found.")
|
|
|
|
self.provider = providers[active_name]
|
|
self.headers = {
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Bearer {self.provider.get('api_key', '')}"
|
|
}
|
|
extra = self.provider.get('extra_headers', {})
|
|
if extra: self.headers.update(extra)
|
|
return (True, "Provider ready")
|
|
|
|
def reset_context(self):
|
|
"""
|
|
Clears history and re-adds system prompt.
|
|
"""
|
|
self.messages = [{"role": "system", "content": self.config.get('system_prompt', '')}]
|
|
return (True, "Context cleared")
|
|
|
|
def guarded_post(self, url, headers={}, payload={}):
|
|
"""
|
|
Performs a POST request trying to bypass possible 429 errors with heuristics.
|
|
"""
|
|
response = requests.post(url, headers=headers, json=payload)
|
|
if response.status_code == 429:
|
|
restext = response.text
|
|
try:
|
|
resdata = response.json()
|
|
restext = data['error']['message']
|
|
except:
|
|
pass
|
|
response.close()
|
|
# try and parse something like "please wait for 0.1 seconds"
|
|
sleeptime = 1.0
|
|
try:
|
|
sleeptime = float(re.search(r'\d+\.\d+', restext).group(0))
|
|
except:
|
|
pass
|
|
time.sleep(sleeptime + 0.1)
|
|
return self.guarded_post(url, headers, payload)
|
|
else:
|
|
data = response.json()
|
|
response.close()
|
|
return data
|
|
|
|
def get_completion(self, user_input):
|
|
"""
|
|
Sends request to LLM.
|
|
"""
|
|
self.messages.append({"role": "user", "content": user_input})
|
|
|
|
if len(self.messages) > (self.message_limit + 1):
|
|
self.messages = [self.messages[0]] + self.messages[-self.message_limit:]
|
|
|
|
payload = {
|
|
"model": self.provider.get('model'),
|
|
"messages": self.messages,
|
|
"temperature": self.config.get('temperature', 0.7),
|
|
"stream": False
|
|
}
|
|
|
|
url = f"{self.provider.get('base_url').rstrip('/')}/chat/completions"
|
|
|
|
try:
|
|
data = self.guarded_post(url, self.headers, payload)
|
|
bot_content = data['choices'][0]['message']['content']
|
|
self.messages.append({"role": "assistant", "content": bot_content})
|
|
return (True, bot_content)
|
|
except Exception as e:
|
|
if len(self.messages) > 0: self.messages.pop()
|
|
return (False, f"Error: {e}")
|
|
|
|
def get_last_response(self):
|
|
"""
|
|
Get the most recent assistant's response.
|
|
"""
|
|
idx = len(self.messages) - 1
|
|
while idx > 0 and self.messages[idx]['role'] != 'assistant':
|
|
idx -= 1
|
|
if idx == 0:
|
|
return (False, 'No assistant messages detected')
|
|
else:
|
|
return (True, self.messages[idx]['content'])
|
|
|
|
def export(self):
|
|
"""
|
|
Export the entire conversation as a string.
|
|
"""
|
|
out = ''
|
|
for msg in self.messages:
|
|
out += f'{msg['role']}: {msg['content'].strip()}\n'
|
|
return (True, out.strip())
|
|
|
|
def add_file(self, fname):
|
|
"""
|
|
Add a (text) file to the context.
|
|
"""
|
|
try:
|
|
with open(fname, 'r') as f:
|
|
text = f.read()
|
|
prompt = f'Contents of the file {fname}:\n\n{text}'
|
|
self.messages.append({'role': 'user', 'content': prompt})
|
|
return (True, 'File content added')
|
|
except:
|
|
return (False, 'Could not read the file')
|
|
|
|
def list_models(self):
|
|
"""
|
|
Lists available models for the active provider.
|
|
"""
|
|
url = f"{self.provider.get('base_url').rstrip('/')}/models"
|
|
try:
|
|
response = requests.get(url, headers=self.headers)
|
|
data = response.json()
|
|
response.close()
|
|
res = []
|
|
if 'data' in data:
|
|
res = [m.get('id') for m in data['data']]
|
|
return True, res
|
|
except Exception as e:
|
|
return False, f"Error: {e}"
|