update formatting
restructure project add cancellation token to cancel mcp server collection from serving
This commit is contained in:
Generated
+4293
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "own_assist_cli"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ollama-rs = { version = "0.3.4", features = ["macros", "headers"] }
|
||||
tokio = { version = "1.50.0", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
|
||||
rmcp = { version = "1.3.0", features = ["client"] }
|
||||
log = { version = "0.4.29" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
url = "2.5.8"
|
||||
own_mcp = { path = "../own_mcp" }
|
||||
own_assist_common = { path = "../common" }
|
||||
mcp_server_collection = { path = "../mcp_server_collection", optional = true}
|
||||
thiserror = "2.0.18"
|
||||
dialoguer = "0.12.0"
|
||||
indicatif = "0.18.4"
|
||||
console = "0.16.3"
|
||||
clap = { version = "4.6.0", features = ["derive"] }
|
||||
base64 = "0.22.1"
|
||||
cpal = "0.17.3"
|
||||
rodio = "0.22.2"
|
||||
tokio-util = { version = "0.7.18", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["built-in-mcp-collection"]
|
||||
built-in-mcp-collection = ["dep:mcp_server_collection", "dep:tokio-util"]
|
||||
@@ -0,0 +1,156 @@
|
||||
use ollama_rs::Ollama;
|
||||
use ollama_rs::headers::{HeaderMap, HeaderValue};
|
||||
use own_assist_common::config_from_file;
|
||||
use own_assist_common::config_loader::ConfigLoadingError;
|
||||
use own_mcp::audio::{AudioClient, AudioClientTrait};
|
||||
use own_mcp::mcp::chat::{ChatError, ToolPermission};
|
||||
use own_mcp::mcp::{MCPClient, guaranteed_mcp_server_name};
|
||||
use own_mcp::{AgentChat, mcp};
|
||||
use rmcp::model::Implementation;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub interface: HumanInterface,
|
||||
permissions: Option<Vec<PermissionConfig>>,
|
||||
ollama: OllamaConfig,
|
||||
audio: Option<AudioConfig>,
|
||||
#[serde(rename = "mcp-servers")]
|
||||
mcp_servers: Vec<MCPServerConfig>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_file() -> Result<Self, ConfigLoadingError> {
|
||||
config_from_file("assist.toml")
|
||||
}
|
||||
|
||||
pub fn ollama_config(&self) -> &OllamaConfig {
|
||||
&self.ollama
|
||||
}
|
||||
|
||||
pub fn ollama_instance(&self) -> Ollama {
|
||||
let mut ollama_headers = HeaderMap::new();
|
||||
|
||||
if let Some(authorization_header) = &self.ollama.authorization {
|
||||
ollama_headers.append(
|
||||
"Authorization",
|
||||
HeaderValue::from_str(authorization_header).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let ollama_default_url = Url::parse("http://127.0.0.1:11434").unwrap();
|
||||
let mut ollama = Ollama::from_url(self.ollama.url.clone().unwrap_or(ollama_default_url));
|
||||
ollama.set_headers(Some(ollama_headers));
|
||||
|
||||
ollama
|
||||
}
|
||||
|
||||
pub fn audio_client(&self) -> Option<AudioClient> {
|
||||
if let Some(audio_config) = &self.audio {
|
||||
let audio_server = &audio_config.server;
|
||||
let mut audio_client = AudioClient::new(audio_server.url.clone());
|
||||
if let Some(authorization) = &audio_server.authorization {
|
||||
audio_client = audio_client.with_authorization(authorization.clone());
|
||||
}
|
||||
Some(audio_client)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mcp_clients(
|
||||
&self,
|
||||
) -> Result<HashMap<String, MCPClient>, rmcp::service::ClientInitializeError> {
|
||||
let mut mcp_clients: HashMap<String, MCPClient> = HashMap::new();
|
||||
|
||||
for mcp_server in &self.mcp_servers {
|
||||
let client = mcp::get_client(
|
||||
mcp_server.url.as_str(),
|
||||
mcp_server.authorization.clone(),
|
||||
Implementation::new("own_assist", env!("CARGO_PKG_VERSION")),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let server_name = guaranteed_mcp_server_name(mcp_server.name.clone(), &client);
|
||||
let server_data = client;
|
||||
|
||||
mcp_clients.insert(server_name.clone(), server_data);
|
||||
}
|
||||
|
||||
Ok(mcp_clients)
|
||||
}
|
||||
|
||||
pub async fn set_tool_permissions(&self, agent_chat: &mut AgentChat) -> Result<(), ChatError> {
|
||||
if let Some(permissions) = &self.permissions
|
||||
&& !permissions.is_empty()
|
||||
{
|
||||
for permission_config in permissions {
|
||||
log::info!(
|
||||
"set permission for `{}` to {:?}",
|
||||
permission_config.tool_name,
|
||||
permission_config.permission
|
||||
);
|
||||
agent_chat.set_permission(
|
||||
permission_config.tool_name.clone(),
|
||||
permission_config.permission,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
log::debug!("no permissions were provided.")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub enum HumanInterface {
|
||||
#[default]
|
||||
Cli,
|
||||
Audio,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AudioConfig {
|
||||
server: AudioServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AudioServerConfig {
|
||||
url: Url,
|
||||
authorization: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OllamaConfig {
|
||||
pub url: Option<Url>,
|
||||
pub model: OllamaModelConfig,
|
||||
pub authorization: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OllamaModelConfig {
|
||||
#[serde(rename = "from-model")]
|
||||
pub from_model: String,
|
||||
pub name: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub context_size: Option<u64>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MCPServerConfig {
|
||||
pub name: Option<String>,
|
||||
pub url: Url,
|
||||
pub authorization: Option<String>, // should probably implement oauth some time // actually, fuck oauth
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PermissionConfig {
|
||||
#[serde(rename = "tool-name")]
|
||||
pub tool_name: String,
|
||||
pub permission: ToolPermission,
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use crate::human_interface::{HumanInterface, HumanInterfaceError};
|
||||
use crate::tlt;
|
||||
use crate::translate;
|
||||
use cpal::traits::DeviceTrait;
|
||||
use own_mcp::audio::models::VoiceRequest;
|
||||
use own_mcp::audio::{AudioClient, AudioClientTrait};
|
||||
use own_mcp::mcp::chat::PermissionAnswer;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tokio::fs::create_dir_all;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
const TEMPORARY_AUDIO_PATH: &str = "temporary_audio";
|
||||
|
||||
pub struct Audio {
|
||||
client: AudioClient,
|
||||
device: cpal::Device,
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub async fn new(
|
||||
client: AudioClient,
|
||||
device: cpal::Device,
|
||||
) -> Result<Self, HumanInterfaceError> {
|
||||
create_dir_all(TEMPORARY_AUDIO_PATH)
|
||||
.await
|
||||
.map_err(HumanInterfaceError::IoError)?;
|
||||
Ok(Self { client, device })
|
||||
}
|
||||
|
||||
async fn wait_for_input_line(expected_line: &str) -> Result<(), io::Error> {
|
||||
loop {
|
||||
let stdin_reader = tokio::io::BufReader::new(tokio::io::stdin());
|
||||
log::trace!("waiting for '{expected_line}' to continue");
|
||||
if let Some(line) = stdin_reader.lines().next_line().await? {
|
||||
log::debug!("Received line: {line:?}");
|
||||
if line == expected_line {
|
||||
log::trace!("stopped waiting");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_decision() -> Result<PermissionAnswer, io::Error> {
|
||||
const GRANTED_LINE: &str = "y";
|
||||
const DENIED_LINE: &str = "n";
|
||||
|
||||
loop {
|
||||
let stdin_reader = tokio::io::BufReader::new(tokio::io::stdin());
|
||||
log::trace!("wating for '{GRANTED_LINE}' or '{DENIED_LINE}' to continue");
|
||||
if let Some(line) = stdin_reader.lines().next_line().await? {
|
||||
log::debug!("Received line: {line:?}");
|
||||
|
||||
match line.as_str() {
|
||||
GRANTED_LINE => return Ok(PermissionAnswer::Granted),
|
||||
DENIED_LINE => return Ok(PermissionAnswer::Denied),
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn play_text(&self, text: String) -> Result<(), HumanInterfaceError> {
|
||||
let bytes = self
|
||||
.client
|
||||
.tts(VoiceRequest { text, config: None })
|
||||
.await
|
||||
.map_err(|e| HumanInterfaceError::Other(format!("Failed getting tts: {e}")))?;
|
||||
|
||||
let wav_file_path = Path::new(TEMPORARY_AUDIO_PATH).join("tts.wav");
|
||||
|
||||
tokio::fs::write(&wav_file_path, &bytes).await?;
|
||||
|
||||
let handle =
|
||||
rodio::DeviceSinkBuilder::open_default_sink().expect("open default audio stream");
|
||||
let file = std::fs::File::open(wav_file_path)?;
|
||||
let player = rodio::play(handle.mixer(), file)
|
||||
.map_err(|e| HumanInterfaceError::Other(format!("Failed playing audio: {e}")))?;
|
||||
|
||||
player.sleep_until_end();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanInterface for Audio {
|
||||
async fn agent_message(&mut self, message: String) -> Result<(), HumanInterfaceError> {
|
||||
self.play_text(message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expect_user_message(&mut self) -> Result<String, HumanInterfaceError> {
|
||||
use own_mcp::audio::recording::{samples_to_wav, start, stop_and_take_data};
|
||||
|
||||
const RECORDING_START: &str = "r";
|
||||
const RECORDING_STOP: &str = "s";
|
||||
|
||||
let input_configs = self.device.default_input_config().map_err(|e| {
|
||||
HumanInterfaceError::Other(format!("Could not get supported input configs: {e}"))
|
||||
})?;
|
||||
|
||||
Self::wait_for_input_line(RECORDING_START).await?;
|
||||
let recording_handler = start(self.device.clone(), input_configs);
|
||||
Self::wait_for_input_line(RECORDING_STOP).await?;
|
||||
|
||||
let (samples, wav_spec) = stop_and_take_data(recording_handler)
|
||||
.map_err(|e| HumanInterfaceError::Other(format!("failed recording: {e}")))?;
|
||||
|
||||
let wav_file_path = Path::new(TEMPORARY_AUDIO_PATH).join("recording.wav");
|
||||
|
||||
samples_to_wav(samples, &wav_spec, &wav_file_path)
|
||||
.map_err(|e| HumanInterfaceError::Other(format!("hound error: {e}")))?;
|
||||
|
||||
let transcription = self
|
||||
.client
|
||||
.transcribe(wav_file_path)
|
||||
.await
|
||||
.map_err(|e| HumanInterfaceError::Other(format!("failed to transcribe: {e}")))?;
|
||||
|
||||
log::info!("transcription: {transcription:?}");
|
||||
|
||||
Ok(transcription.text)
|
||||
}
|
||||
|
||||
async fn ask_for_permission(
|
||||
&self,
|
||||
mcp_server_name: String,
|
||||
tool_name: String,
|
||||
) -> Result<PermissionAnswer, HumanInterfaceError> {
|
||||
self.play_text(tlt!(
|
||||
"tool_allow_question_audio",
|
||||
mcp_server_name,
|
||||
tool_name
|
||||
))
|
||||
.await?;
|
||||
|
||||
let decision = Self::wait_for_decision().await?;
|
||||
log::debug!("decision: {decision:?}");
|
||||
Ok(decision)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cpal::traits::HostTrait;
|
||||
|
||||
const AUTH_HEADER: &str = include_str!(".AUTH_HEADER");
|
||||
|
||||
async fn get_test_human_interface() -> Audio {
|
||||
Audio::new(
|
||||
AudioClient::new(url::Url::parse("https://audio.mboemer.de").unwrap())
|
||||
.with_authorization(AUTH_HEADER.to_string()),
|
||||
cpal::Host::default().default_input_device().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tts_fetching() {
|
||||
let mut human_interface = get_test_human_interface().await;
|
||||
|
||||
human_interface
|
||||
.agent_message(String::from("Peter ist jetzt in deinem PC."))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tts_playing() {
|
||||
let human_interface = get_test_human_interface().await;
|
||||
human_interface
|
||||
.play_text(String::from("Peter ist jetzt in deinem PC."))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::human_interface::{HumanInterface, HumanInterfaceError};
|
||||
use crate::tlt;
|
||||
use crate::translate;
|
||||
use console::style;
|
||||
use dialoguer::theme::ColorfulTheme;
|
||||
use dialoguer::{Confirm, Input};
|
||||
use own_mcp::mcp::chat::PermissionAnswer;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct CommandLine {
|
||||
progress_bar: Option<indicatif::ProgressBar>,
|
||||
}
|
||||
|
||||
impl CommandLine {
|
||||
pub fn new() -> CommandLine {
|
||||
CommandLine { progress_bar: None }
|
||||
}
|
||||
|
||||
fn spinner() -> indicatif::ProgressBar {
|
||||
indicatif::ProgressBar::new_spinner()
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanInterface for CommandLine {
|
||||
async fn agent_message(&mut self, message: String) -> Result<(), HumanInterfaceError> {
|
||||
// stopped waiting for message
|
||||
if let Some(progress_bar) = &self.progress_bar {
|
||||
progress_bar.finish_and_clear();
|
||||
self.progress_bar = None;
|
||||
}
|
||||
|
||||
println!("{}: {}", style(tlt!("assistant")).bold().italic(), message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn expect_user_message(&mut self) -> Result<String, HumanInterfaceError> {
|
||||
let user_message: String = Input::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt(tlt!("you"))
|
||||
.interact_text()
|
||||
.map_err(|e| HumanInterfaceError::IoError(e.into()))?;
|
||||
|
||||
// waiting for agent response
|
||||
self.progress_bar = Some(Self::spinner());
|
||||
self.progress_bar
|
||||
.clone()
|
||||
.unwrap()
|
||||
.enable_steady_tick(Duration::from_millis(100));
|
||||
|
||||
Ok(user_message)
|
||||
}
|
||||
|
||||
async fn ask_for_permission(
|
||||
&self,
|
||||
mcp_server_name: String,
|
||||
tool_name: String,
|
||||
) -> Result<PermissionAnswer, HumanInterfaceError> {
|
||||
if let Some(progress_bar) = &self.progress_bar {
|
||||
progress_bar.finish_and_clear();
|
||||
}
|
||||
|
||||
let confirmation = Confirm::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt(tlt!("tool_allow_question_cli", mcp_server_name, tool_name))
|
||||
.interact()
|
||||
.map_err(|e| HumanInterfaceError::IoError(e.into()))?;
|
||||
|
||||
match confirmation {
|
||||
true => Ok(PermissionAnswer::Granted),
|
||||
false => Ok(PermissionAnswer::Denied),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
pub mod audio;
|
||||
pub mod cli;
|
||||
|
||||
use own_mcp::mcp::chat::PermissionAnswer;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HumanInterfaceError {
|
||||
#[error(transparent)]
|
||||
IoError(#[from] std::io::Error),
|
||||
#[error("other error in human interface: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
pub trait HumanInterface {
|
||||
fn agent_message(
|
||||
&mut self,
|
||||
message: String,
|
||||
) -> impl Future<Output = Result<(), HumanInterfaceError>>;
|
||||
|
||||
fn expect_user_message(&mut self) -> impl Future<Output = Result<String, HumanInterfaceError>>;
|
||||
|
||||
fn ask_for_permission(
|
||||
&self,
|
||||
mcp_server_name: String,
|
||||
tool_name: String,
|
||||
) -> impl Future<Output = Result<PermissionAnswer, HumanInterfaceError>>;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use rmcp::serde_json;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, OnceLock, RwLock};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Language {
|
||||
translations: HashMap<String, String>,
|
||||
}
|
||||
|
||||
static LANGUAGES: OnceLock<HashMap<Locale, Language>> = OnceLock::new();
|
||||
static LOCALE_SELECTION: LazyLock<RwLock<Locale>> = LazyLock::new(|| RwLock::new(Locale::DE));
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Locale {
|
||||
DE,
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! include_language {
|
||||
($file:expr) => {
|
||||
serde_json::from_str::<Language>(include_str!($file)).unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
fn load_languages() -> &'static HashMap<Locale, Language> {
|
||||
LANGUAGES
|
||||
.get_or_init(|| HashMap::from([(Locale::DE, include_language!("translations/de.json"))]))
|
||||
}
|
||||
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// let formatted = format_dynamically("{...} is cool!".to_string(),
|
||||
/// vec!["Rust".to_string()]);
|
||||
/// assert_eq!(formatted, "Rust is cool!");
|
||||
/// ```
|
||||
fn format_dynamically(mut template: String, arguments: Vec<impl Into<String>>) -> String {
|
||||
let replaced_str = "{...}";
|
||||
|
||||
for arg in arguments {
|
||||
template = template.replacen(replaced_str, arg.into().as_str(), 1);
|
||||
}
|
||||
|
||||
template
|
||||
}
|
||||
|
||||
pub fn translate(template_name: String, arguments: Vec<impl Into<String>>) -> String {
|
||||
let locale: Locale = *LOCALE_SELECTION.read().unwrap();
|
||||
let languages = load_languages();
|
||||
let translation = languages
|
||||
.get(&locale)
|
||||
.unwrap()
|
||||
.translations
|
||||
.get(&template_name)
|
||||
.unwrap();
|
||||
format_dynamically(translation.clone(), arguments)
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! tlt {
|
||||
($template_name:tt) => {
|
||||
translate($template_name.to_string(), Vec::<String>::new())
|
||||
};
|
||||
($template_name:tt, $($args:tt)*) => {
|
||||
translate($template_name.to_string(), vec![$($args)*])
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dynamic_formatting() {
|
||||
let formatted = format_dynamically("{...} is cool!".to_string(), vec!["Rust".to_string()]);
|
||||
assert_eq!(formatted, "Rust is cool!");
|
||||
|
||||
let formatted = format_dynamically(
|
||||
"{...}, {...} and {...} are three consecutive numbers.".to_string(),
|
||||
vec!["1".to_string(), "2".to_string(), "3".to_string()],
|
||||
);
|
||||
assert_eq!(formatted, "1, 2 and 3 are three consecutive numbers.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"translations": {
|
||||
"system_prompt_cli": "Du bist ein Assistent. Wichtiger als deine Antworten sind deine Aktionen. Nutze bitte die tools, falls du sie brauchst um Informationen zu bekommen (z.B. über das aktuelle Datum oder den aktuellen Wochentag). Du bist in einem Agent Loop und kannst mehrere Tools hintereinander nutzen. Falls der Nutzer das Nutzen eines Tools ablehnt, sag ihm bescheid, dass du es brauchst.",
|
||||
"system_prompt_audio": "Du bist ein Assistent, der per Sprache bedient wird. Du erhälst die Transkription und dein Output wird per Sprache ausgegeben und sollte dementsprechend auch kurz sein und kein Markdown enthalten. Wichtiger als deine Antworten sind deine Aktionen. Nutze bitte die tools, falls du sie brauchst um Informationen zu bekommen (z.B. über das aktuelle Datum oder den aktuellen Wochentag). Du bist in einem Agent Loop und kannst mehrere Tools hintereinander nutzen. Falls der Nutzer das Nutzen eines Tools ablehnt, sag ihm bescheid, dass du es brauchst.",
|
||||
"you": "Du",
|
||||
"assistant": "Assistent",
|
||||
"username": "Nutzername",
|
||||
"password": "Passwort",
|
||||
"model_download_needed": "Das Modell '{...}' ist noch nicht installiert und muss zuerst heruntergeladen werden",
|
||||
"tool_allow_question_cli": "Benutzung von {...}:{...} zulassen",
|
||||
"tool_allow_question_audio": "Benutzung von {...} des Dienstes {...} zulassen?"
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
mod config;
|
||||
mod human_interface;
|
||||
mod i18n;
|
||||
mod model;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::human_interface::HumanInterface;
|
||||
use crate::i18n::translate;
|
||||
use crate::model::create_model_from_config;
|
||||
use base64::Engine;
|
||||
use clap::Parser;
|
||||
use cpal::traits::HostTrait;
|
||||
use own_assist_common::config_loader::ConfigLoadingError;
|
||||
use own_assist_common::{exit_msg, init_tracing_subscriber};
|
||||
use own_mcp::AgentChat;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// partial mcp client with cli and voice interaction
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, long_about = None)]
|
||||
struct Args {
|
||||
/// generate an encoded basic auth header
|
||||
#[clap(long, short, action)]
|
||||
basic_auth: bool,
|
||||
}
|
||||
|
||||
fn basic_auth_tool() {
|
||||
let username: String = dialoguer::Input::new()
|
||||
.with_prompt(tlt!("username"))
|
||||
.interact_text()
|
||||
.unwrap();
|
||||
|
||||
let password: String = dialoguer::Password::new()
|
||||
.with_prompt(tlt!("password"))
|
||||
.interact()
|
||||
.unwrap();
|
||||
|
||||
let encoded = base64::prelude::BASE64_STANDARD.encode(format!("{}:{}", username, password));
|
||||
println!("Basic {}", encoded);
|
||||
}
|
||||
|
||||
async fn chat_loop(mut human_interface: impl HumanInterface, mut agent_chat: AgentChat) {
|
||||
loop {
|
||||
let user_message = human_interface.expect_user_message().await.unwrap();
|
||||
let immutable_interface = &human_interface;
|
||||
let agent_message = agent_chat
|
||||
.message(user_message, async |mcp_server_name, tool_name| {
|
||||
immutable_interface
|
||||
.ask_for_permission(mcp_server_name, tool_name)
|
||||
.await
|
||||
.inspect_err(exit_msg!(
|
||||
"Human interface error while asking for permission"
|
||||
))
|
||||
.unwrap()
|
||||
})
|
||||
.await
|
||||
.inspect_err(exit_msg!("Failed communicating with agent."))
|
||||
.unwrap();
|
||||
human_interface
|
||||
.agent_message(agent_message.content)
|
||||
.await
|
||||
.inspect_err(exit_msg!(
|
||||
"Human interface error while trying to display message"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "built-in-mcp-collection")]
|
||||
fn launch_mcp_server() -> Result<JoinHandle<CancellationToken>, ConfigLoadingError> {
|
||||
let config = mcp_server_collection::config::Config::from_file()?;
|
||||
|
||||
let handle = tokio::task::spawn(mcp_server_collection::serve(config));
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
if args.basic_auth {
|
||||
basic_auth_tool();
|
||||
return;
|
||||
}
|
||||
|
||||
init_tracing_subscriber();
|
||||
|
||||
#[cfg(feature = "built-in-mcp-collection")]
|
||||
let mcp_server_task = launch_mcp_server()
|
||||
.inspect_err(exit_msg!("error loading server.toml"))
|
||||
.unwrap();
|
||||
|
||||
let config = Config::from_file()
|
||||
.inspect_err(exit_msg!("error loading assist.toml"))
|
||||
.unwrap();
|
||||
|
||||
let ollama = config.ollama_instance();
|
||||
let model_name = &config.ollama_config().model.name;
|
||||
|
||||
create_model_from_config(&ollama, &config.ollama_config().model, &config.interface)
|
||||
.await
|
||||
.inspect_err(exit_msg!(format!(
|
||||
"failed creating ollama model `{model_name}`"
|
||||
)))
|
||||
.unwrap();
|
||||
|
||||
let mcp_clients = config
|
||||
.mcp_clients()
|
||||
.await
|
||||
.inspect_err(exit_msg!("failed creating MCP clients"))
|
||||
.unwrap();
|
||||
|
||||
let mut agent_chat = AgentChat::new(ollama, model_name.clone(), mcp_clients)
|
||||
.await
|
||||
.inspect_err(exit_msg!("failed to create agent"))
|
||||
.unwrap();
|
||||
config.set_tool_permissions(&mut agent_chat).await.unwrap();
|
||||
|
||||
log::debug!(
|
||||
"all tools: {:#?}",
|
||||
agent_chat.get_all_tools().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
match config.interface {
|
||||
config::HumanInterface::Cli => {
|
||||
let human_interface = human_interface::cli::CommandLine::new();
|
||||
chat_loop(human_interface, agent_chat).await;
|
||||
}
|
||||
config::HumanInterface::Audio => {
|
||||
match config.audio_client() {
|
||||
Some(audio_client) => {
|
||||
let human_interface = human_interface::audio::Audio::new(
|
||||
audio_client,
|
||||
cpal::Host::default()
|
||||
.default_input_device()
|
||||
.expect("no default device"),
|
||||
)
|
||||
.await
|
||||
.inspect_err(exit_msg!("failed creating audio client"))
|
||||
.unwrap();
|
||||
|
||||
chat_loop(human_interface, agent_chat).await;
|
||||
}
|
||||
None => {
|
||||
eprintln!("audio client was not configured");
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "built-in-mcp-collection")]
|
||||
mcp_server_task.await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::config::{HumanInterface, OllamaModelConfig};
|
||||
use crate::tlt;
|
||||
use crate::translate;
|
||||
use ollama_rs::Ollama;
|
||||
use ollama_rs::error::OllamaError;
|
||||
use ollama_rs::models::ModelOptions;
|
||||
use ollama_rs::models::create::{CreateModelRequest, CreateModelStatus};
|
||||
|
||||
pub async fn create_model_from_config(
|
||||
ollama: &Ollama,
|
||||
config: &OllamaModelConfig,
|
||||
interface: &HumanInterface,
|
||||
) -> Result<CreateModelStatus, OllamaError> {
|
||||
let model_options = ModelOptions::default()
|
||||
.num_ctx(config.context_size.unwrap_or(2048)) // 2048 is the ollama default.
|
||||
.temperature(config.temperature.unwrap_or(0.8)); // 0.8 is the ollama default
|
||||
|
||||
let system_prompt = config.system_prompt.clone().unwrap_or(match interface {
|
||||
HumanInterface::Cli => tlt!("system_prompt_cli"),
|
||||
HumanInterface::Audio => tlt!("system_prompt_audio"),
|
||||
});
|
||||
|
||||
// print message to warn user of long waiting times
|
||||
if let Ok(models) = ollama.list_local_models().await
|
||||
&& !models.iter().any(|m| m.name == config.from_model)
|
||||
{
|
||||
let message = tlt!("model_download_needed", config.from_model.clone());
|
||||
log::info!("{}", message);
|
||||
println!("{}", console::style(message).yellow().bold());
|
||||
}
|
||||
|
||||
ollama
|
||||
.create_model(
|
||||
CreateModelRequest::new(config.name.clone())
|
||||
.from_model(config.from_model.clone())
|
||||
.parameters(model_options)
|
||||
.system(system_prompt),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Reference in New Issue
Block a user