pub(crate) mod chat;
mod http;
mod script;
pub use chat::AssistantChat;
pub use http::{Anthropic, OpenAiCompatible, OpenAiSpeech, OpenAiTranscription};
pub use script::{Say, ScriptedProvider};
use crate::ctx::BoxFuture;
use crate::i18n::Text;
use crate::screen::ScreenDef;
use crate::security::RateLimit;
use axum::body::Bytes;
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
User,
Assistant,
Tool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Json,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: Role,
#[serde(default)]
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<Json>,
}
impl ChatMessage {
pub fn user(text: impl Into<String>) -> Self {
ChatMessage { role: Role::User, text: text.into(), tool_calls: Vec::new(), tool_call_id: None, raw: None }
}
pub fn assistant(text: impl Into<String>) -> Self {
ChatMessage { role: Role::Assistant, text: text.into(), tool_calls: Vec::new(), tool_call_id: None, raw: None }
}
pub fn tool(call_id: impl Into<String>, text: impl Into<String>) -> Self {
ChatMessage {
role: Role::Tool,
text: text.into(),
tool_calls: Vec::new(),
tool_call_id: Some(call_id.into()),
raw: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub schema: Json,
}
#[derive(Clone, Debug)]
pub struct ChatRequest {
pub system: String,
pub messages: Vec<ChatMessage>,
pub tools: Vec<ToolSpec>,
pub max_tokens: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ChatEvent {
Text(String),
ToolCall(ToolCall),
Usage { input: u32, output: u32 },
Raw(Json),
}
pub type ChatStream = tokio::sync::mpsc::Receiver<crate::Result<ChatEvent>>;
pub const STREAM_BUFFER: usize = 32;
pub trait LlmProvider: Send + Sync + 'static {
fn chat(&self, request: ChatRequest) -> BoxFuture<crate::Result<ChatStream>>;
}
#[derive(Clone, Debug)]
pub struct Audio {
pub bytes: Bytes,
pub mime: String,
}
pub trait SpeechToText: Send + Sync + 'static {
fn transcribe(&self, audio: Audio, locale: String) -> BoxFuture<crate::Result<String>>;
}
pub struct SpeechStream {
pub mime: String,
pub chunks: tokio::sync::mpsc::Receiver<crate::Result<Bytes>>,
}
pub trait TextToSpeech: Send + Sync + 'static {
fn speak(&self, text: String, locale: String) -> BoxFuture<crate::Result<SpeechStream>>;
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ChatKey {
pub tenant: String,
pub user: String,
}
pub trait ChatStore: Send + Sync + 'static {
fn load(&self, key: &ChatKey, limit: usize) -> BoxFuture<crate::Result<Vec<ChatMessage>>>;
fn append(&self, key: &ChatKey, messages: Vec<ChatMessage>) -> BoxFuture<crate::Result<()>>;
fn clear(&self, key: &ChatKey) -> BoxFuture<crate::Result<()>>;
}
pub struct MemoryChatStore {
chats: Mutex<HashMap<ChatKey, (u64, VecDeque<ChatMessage>)>>,
max_users: usize,
max_messages: usize,
}
impl MemoryChatStore {
pub fn new(max_users: usize, max_messages: usize) -> Self {
MemoryChatStore {
chats: Mutex::new(HashMap::new()),
max_users: max_users.max(1),
max_messages: max_messages.max(2),
}
}
pub fn len(&self) -> usize {
self.chats.lock().map(|c| c.len()).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Default for MemoryChatStore {
fn default() -> Self {
MemoryChatStore::new(1_000, 60)
}
}
static TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
impl ChatStore for MemoryChatStore {
fn load(&self, key: &ChatKey, limit: usize) -> BoxFuture<crate::Result<Vec<ChatMessage>>> {
let list = self
.chats
.lock()
.ok()
.and_then(|c| c.get(key).map(|(_, m)| m.iter().skip(m.len().saturating_sub(limit)).cloned().collect()))
.unwrap_or_default();
Box::pin(async move { Ok(list) })
}
fn append(&self, key: &ChatKey, messages: Vec<ChatMessage>) -> BoxFuture<crate::Result<()>> {
let tick = TICK.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut chats) = self.chats.lock() {
if !chats.contains_key(key) && chats.len() >= self.max_users {
let oldest = chats.iter().min_by_key(|(_, (at, _))| *at).map(|(k, _)| k.clone());
if let Some(k) = oldest {
chats.remove(&k);
}
}
let (at, list) = chats.entry(key.clone()).or_default();
*at = tick;
list.extend(messages);
while list.len() > self.max_messages {
list.pop_front();
}
}
Box::pin(async { Ok(()) })
}
fn clear(&self, key: &ChatKey) -> BoxFuture<crate::Result<()>> {
if let Ok(mut chats) = self.chats.lock() {
chats.remove(key);
}
Box::pin(async { Ok(()) })
}
}
#[derive(Clone, Copy, Debug)]
pub struct Limits {
pub message_chars: usize,
pub history: usize,
pub rounds: usize,
pub output_tokens: u32,
pub tool_result_chars: usize,
pub audio_bytes: usize,
pub speech_chars: usize,
pub idle: Duration,
}
impl Default for Limits {
fn default() -> Self {
Limits {
message_chars: 2_000,
history: 30,
rounds: 6,
output_tokens: 1_024,
tool_result_chars: 6_000,
audio_bytes: MAX_AUDIO_BYTES,
speech_chars: 2_000,
idle: Duration::from_secs(60),
}
}
}
pub const MAX_AUDIO_BYTES: usize = 4 * 1024 * 1024;
pub struct Assistant {
pub(crate) provider: Arc<dyn LlmProvider>,
pub(crate) stt: Option<Arc<dyn SpeechToText>>,
pub(crate) tts: Option<Arc<dyn TextToSpeech>>,
pub(crate) voice: bool,
pub(crate) store: Arc<dyn ChatStore>,
pub(crate) permission: Option<&'static str>,
pub(crate) instructions: String,
pub(crate) suggestions: Vec<(&'static str, Text)>,
pub(crate) launcher: bool,
pub(crate) writes: bool,
pub(crate) limits: Limits,
pub(crate) rate: RateLimit,
pub(crate) tokens: Option<RateLimit>,
}
impl Assistant {
pub fn new(provider: impl LlmProvider) -> Self {
Assistant {
provider: Arc::new(provider),
stt: None,
tts: None,
voice: true,
store: Arc::new(MemoryChatStore::default()),
permission: None,
instructions: String::new(),
suggestions: Vec::new(),
launcher: true,
writes: true,
limits: Limits::default(),
rate: RateLimit::per_user(20, Duration::from_secs(60)),
tokens: Some(RateLimit::per_user(200_000, Duration::from_secs(3_600))),
}
}
pub fn speech_to_text(mut self, stt: impl SpeechToText) -> Self {
self.stt = Some(Arc::new(stt));
self
}
pub fn text_to_speech(mut self, tts: impl TextToSpeech) -> Self {
self.tts = Some(Arc::new(tts));
self
}
pub fn no_voice(mut self) -> Self {
self.voice = false;
self
}
pub fn store(mut self, store: impl ChatStore) -> Self {
self.store = Arc::new(store);
self
}
pub fn permission(mut self, permission: &'static str) -> Self {
self.permission = Some(permission);
self
}
pub fn instructions(mut self, text: impl Into<String>) -> Self {
self.instructions = text.into();
self
}
pub fn suggest<S: ScreenDef>(mut self, _screen: S, prompt: impl Into<Text>) -> Self {
self.suggestions.push((S::META.name, prompt.into()));
self
}
pub fn suggest_anywhere(mut self, prompt: impl Into<Text>) -> Self {
self.suggestions.push(("", prompt.into()));
self
}
pub fn launcher(mut self, on: bool) -> Self {
self.launcher = on;
self
}
pub fn read_only(mut self) -> Self {
self.writes = false;
self
}
pub fn limits(mut self, limits: Limits) -> Self {
self.limits = Limits { audio_bytes: limits.audio_bytes.min(MAX_AUDIO_BYTES), ..limits };
self
}
pub fn rate_limit(mut self, limit: RateLimit) -> Self {
self.rate = limit;
self
}
pub fn token_limit(mut self, limit: Option<RateLimit>) -> Self {
self.tokens = limit;
self
}
}
pub(crate) fn clip(text: &str, max: usize) -> &str {
match text.char_indices().nth(max) {
Some((i, _)) => &text[..i],
None => text,
}
}