Compare commits
4 Commits
235f84fa19
...
d002d35bde
| Author | SHA1 | Date | |
|---|---|---|---|
| d002d35bde | |||
| c9c3d17db0 | |||
| a909455f97 | |||
| 67381b02db |
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/owlen-tui",
|
||||
"crates/owlen-cli",
|
||||
"crates/owlen-ollama",
|
||||
"crates/owlen-mcp-server",
|
||||
]
|
||||
exclude = []
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ sqlx = { workspace = true }
|
||||
duckduckgo = "0.2.0"
|
||||
reqwest = { workspace = true, features = ["default"] }
|
||||
reqwest_011 = { version = "0.11", package = "reqwest" }
|
||||
path-clean = "1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -14,6 +14,9 @@ pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
|
||||
pub struct Config {
|
||||
/// General application settings
|
||||
pub general: GeneralSettings,
|
||||
/// MCP (Multi-Client-Provider) settings
|
||||
#[serde(default)]
|
||||
pub mcp: McpSettings,
|
||||
/// Provider specific configuration keyed by provider name
|
||||
#[serde(default)]
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
@@ -48,6 +51,7 @@ impl Default for Config {
|
||||
|
||||
Self {
|
||||
general: GeneralSettings::default(),
|
||||
mcp: McpSettings::default(),
|
||||
providers,
|
||||
ui: UiSettings::default(),
|
||||
storage: StorageSettings::default(),
|
||||
@@ -202,6 +206,21 @@ impl Default for GeneralSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP (Multi-Client-Provider) settings
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct McpSettings {
|
||||
#[serde(default)]
|
||||
pub mode: McpMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum McpMode {
|
||||
#[default]
|
||||
Legacy,
|
||||
Enabled,
|
||||
}
|
||||
|
||||
/// Privacy controls governing network access and storage
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivacySettings {
|
||||
|
||||
@@ -91,6 +91,11 @@ impl MessageFormatter {
|
||||
Some(thinking)
|
||||
};
|
||||
|
||||
// If the result is empty but we have thinking content, show a placeholder
|
||||
if result.trim().is_empty() && thinking_result.is_some() {
|
||||
result.push_str("[Thinking...]");
|
||||
}
|
||||
|
||||
(result, thinking_result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,7 @@ pub enum Error {
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
Unknown(String),
|
||||
|
||||
#[error("Not implemented: {0}")]
|
||||
NotImplemented(String),
|
||||
}
|
||||
|
||||
39
crates/owlen-core/src/mcp/client.rs
Normal file
39
crates/owlen-core/src/mcp/client.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use super::{McpToolCall, McpToolDescriptor, McpToolResponse};
|
||||
use crate::{Error, Result};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Trait for a client that can interact with an MCP server
|
||||
#[async_trait]
|
||||
pub trait McpClient: Send + Sync {
|
||||
/// List the tools available on the server
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
|
||||
|
||||
/// Call a tool on the server
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse>;
|
||||
}
|
||||
|
||||
/// Placeholder for a client that connects to a remote MCP server.
|
||||
pub struct RemoteMcpClient;
|
||||
|
||||
impl RemoteMcpClient {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for RemoteMcpClient {
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
// TODO: Implement remote call
|
||||
Err(Error::NotImplemented(
|
||||
"Remote MCP client is not implemented".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn call_tool(&self, _call: McpToolCall) -> Result<McpToolResponse> {
|
||||
// TODO: Implement remote call
|
||||
Err(Error::NotImplemented(
|
||||
"Remote MCP client is not implemented".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::validation::SchemaValidator;
|
||||
use crate::Result;
|
||||
use async_trait::async_trait;
|
||||
use client::McpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub mod client;
|
||||
|
||||
/// Descriptor for a tool exposed over MCP
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolDescriptor {
|
||||
@@ -80,3 +84,26 @@ impl McpServer {
|
||||
fn duration_to_millis(duration: Duration) -> u128 {
|
||||
duration.as_secs() as u128 * 1_000 + u128::from(duration.subsec_millis())
|
||||
}
|
||||
|
||||
pub struct LocalMcpClient {
|
||||
server: McpServer,
|
||||
}
|
||||
|
||||
impl LocalMcpClient {
|
||||
pub fn new(registry: Arc<ToolRegistry>, validator: Arc<SchemaValidator>) -> Self {
|
||||
Self {
|
||||
server: McpServer::new(registry, validator),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for LocalMcpClient {
|
||||
async fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
|
||||
Ok(self.server.list_tools())
|
||||
}
|
||||
|
||||
async fn call_tool(&self, call: McpToolCall) -> Result<McpToolResponse> {
|
||||
self.server.call_tool(call).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
||||
/// use std::sync::Arc;
|
||||
/// use futures::Stream;
|
||||
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
|
||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message};
|
||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message, Role, ChatParameters};
|
||||
/// use owlen_core::Result;
|
||||
///
|
||||
/// // 1. Create a mock provider
|
||||
@@ -31,18 +31,23 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
||||
///
|
||||
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||
/// Ok(vec![ModelInfo {
|
||||
/// id: "mock-model".to_string(),
|
||||
/// provider: "mock".to_string(),
|
||||
/// name: "mock-model".to_string(),
|
||||
/// ..Default::default()
|
||||
/// description: None,
|
||||
/// context_window: None,
|
||||
/// capabilities: vec![],
|
||||
/// supports_tools: false,
|
||||
/// }])
|
||||
/// }
|
||||
///
|
||||
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
|
||||
/// Ok(ChatResponse {
|
||||
/// model: request.model,
|
||||
/// message: Message { role: "assistant".to_string(), content, ..Default::default() },
|
||||
/// ..Default::default()
|
||||
/// message: Message::new(Role::Assistant, content),
|
||||
/// usage: None,
|
||||
/// is_streaming: false,
|
||||
/// is_final: true,
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
@@ -67,8 +72,9 @@ pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
||||
///
|
||||
/// let request = ChatRequest {
|
||||
/// model: "mock-model".to_string(),
|
||||
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }],
|
||||
/// ..Default::default()
|
||||
/// messages: vec![Message::new(Role::User, "Hello".to_string())],
|
||||
/// parameters: ChatParameters::default(),
|
||||
/// tools: None,
|
||||
/// };
|
||||
///
|
||||
/// let response = provider.chat(request).await.unwrap();
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use crate::config::Config;
|
||||
use crate::config::{Config, McpMode};
|
||||
use crate::consent::ConsentManager;
|
||||
use crate::conversation::ConversationManager;
|
||||
use crate::credentials::CredentialManager;
|
||||
use crate::encryption::{self, VaultHandle};
|
||||
use crate::formatting::MessageFormatter;
|
||||
use crate::input::InputBuffer;
|
||||
use crate::mcp::client::{McpClient, RemoteMcpClient};
|
||||
use crate::mcp::{LocalMcpClient, McpToolCall};
|
||||
use crate::model::ModelManager;
|
||||
use crate::provider::{ChatStream, Provider};
|
||||
use crate::storage::{SessionMeta, StorageManager};
|
||||
use crate::tools::{
|
||||
code_exec::CodeExecTool, registry::ToolRegistry, web_search::WebSearchTool,
|
||||
web_search_detailed::WebSearchDetailedTool, Tool,
|
||||
code_exec::CodeExecTool,
|
||||
fs_tools::{ResourcesGetTool, ResourcesListTool},
|
||||
registry::ToolRegistry,
|
||||
web_search::WebSearchTool,
|
||||
web_search_detailed::WebSearchDetailedTool,
|
||||
Tool,
|
||||
};
|
||||
use crate::types::{
|
||||
ChatParameters, ChatRequest, ChatResponse, Conversation, Message, ModelInfo, ToolCall,
|
||||
@@ -46,7 +52,7 @@ pub enum SessionOutcome {
|
||||
/// use owlen_core::provider::{Provider, ChatStream};
|
||||
/// use owlen_core::session::{SessionController, SessionOutcome};
|
||||
/// use owlen_core::storage::StorageManager;
|
||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo};
|
||||
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo, Role};
|
||||
/// use owlen_core::Result;
|
||||
///
|
||||
/// // Mock provider for the example
|
||||
@@ -57,9 +63,10 @@ pub enum SessionOutcome {
|
||||
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
|
||||
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||
/// Ok(ChatResponse {
|
||||
/// model: request.model,
|
||||
/// message: Message::assistant("Hello back!".to_string()),
|
||||
/// ..Default::default()
|
||||
/// usage: None,
|
||||
/// is_streaming: false,
|
||||
/// is_final: true,
|
||||
/// })
|
||||
/// }
|
||||
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
|
||||
@@ -102,6 +109,7 @@ pub struct SessionController {
|
||||
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||
tool_registry: Arc<ToolRegistry>,
|
||||
schema_validator: Arc<SchemaValidator>,
|
||||
mcp_client: Arc<dyn McpClient>,
|
||||
storage: Arc<StorageManager>,
|
||||
vault: Option<Arc<Mutex<VaultHandle>>>,
|
||||
master_key: Option<Arc<Vec<u8>>>,
|
||||
@@ -109,6 +117,90 @@ pub struct SessionController {
|
||||
enable_code_tools: bool, // Whether to enable code execution tools (code client only)
|
||||
}
|
||||
|
||||
fn build_tools(
|
||||
config: &Config,
|
||||
enable_code_tools: bool,
|
||||
consent_manager: Arc<Mutex<ConsentManager>>,
|
||||
credential_manager: Option<Arc<CredentialManager>>,
|
||||
vault: Option<Arc<Mutex<VaultHandle>>>,
|
||||
) -> Result<(Arc<ToolRegistry>, Arc<SchemaValidator>)> {
|
||||
let mut registry = ToolRegistry::new();
|
||||
let mut validator = SchemaValidator::new();
|
||||
|
||||
for (name, schema) in get_builtin_schemas() {
|
||||
if let Err(err) = validator.register_schema(&name, schema) {
|
||||
warn!("Failed to register built-in schema {name}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search")
|
||||
&& config.tools.web_search.enabled
|
||||
&& config.privacy.enable_remote_search
|
||||
{
|
||||
let tool = WebSearchTool::new(
|
||||
consent_manager.clone(),
|
||||
credential_manager.clone(),
|
||||
vault.clone(),
|
||||
);
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
// Register web_search_detailed tool (provides snippets)
|
||||
if config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search") // Same permission as web_search
|
||||
&& config.tools.web_search.enabled
|
||||
&& config.privacy.enable_remote_search
|
||||
{
|
||||
let tool = WebSearchDetailedTool::new(
|
||||
consent_manager.clone(),
|
||||
credential_manager.clone(),
|
||||
vault.clone(),
|
||||
);
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
// Code execution tool - only available in code client
|
||||
if enable_code_tools
|
||||
&& config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "code_exec")
|
||||
&& config.tools.code_exec.enabled
|
||||
{
|
||||
let tool = CodeExecTool::new(config.tools.code_exec.allowed_languages.clone());
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
let resources_list_tool = ResourcesListTool;
|
||||
let resources_get_tool = ResourcesGetTool;
|
||||
validator.register_schema(resources_list_tool.name(), resources_list_tool.schema())?;
|
||||
validator.register_schema(resources_get_tool.name(), resources_get_tool.schema())?;
|
||||
registry.register(resources_list_tool);
|
||||
registry.register(resources_get_tool);
|
||||
|
||||
Ok((Arc::new(registry), Arc::new(validator)))
|
||||
}
|
||||
|
||||
impl SessionController {
|
||||
/// Create a new controller with the given provider and configuration
|
||||
///
|
||||
@@ -175,7 +267,23 @@ impl SessionController {
|
||||
|
||||
let model_manager = ModelManager::new(config.general.model_cache_ttl());
|
||||
|
||||
let mut controller = Self {
|
||||
let (tool_registry, schema_validator) = build_tools(
|
||||
&config,
|
||||
enable_code_tools,
|
||||
consent_manager.clone(),
|
||||
credential_manager.clone(),
|
||||
vault_handle.clone(),
|
||||
)?;
|
||||
|
||||
let mcp_client: Arc<dyn McpClient> = match config.mcp.mode {
|
||||
McpMode::Legacy => Arc::new(LocalMcpClient::new(
|
||||
tool_registry.clone(),
|
||||
schema_validator.clone(),
|
||||
)),
|
||||
McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
|
||||
};
|
||||
|
||||
let controller = Self {
|
||||
provider,
|
||||
conversation,
|
||||
model_manager,
|
||||
@@ -183,8 +291,9 @@ impl SessionController {
|
||||
formatter,
|
||||
config,
|
||||
consent_manager,
|
||||
tool_registry: Arc::new(ToolRegistry::new()),
|
||||
schema_validator: Arc::new(SchemaValidator::new()),
|
||||
tool_registry,
|
||||
schema_validator,
|
||||
mcp_client,
|
||||
storage,
|
||||
vault: vault_handle,
|
||||
master_key,
|
||||
@@ -192,8 +301,6 @@ impl SessionController {
|
||||
enable_code_tools,
|
||||
};
|
||||
|
||||
controller.rebuild_tools()?;
|
||||
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
@@ -415,79 +522,45 @@ impl SessionController {
|
||||
self.credential_manager.as_ref().map(Arc::clone)
|
||||
}
|
||||
|
||||
pub async fn read_file(&self, path: &str) -> Result<String> {
|
||||
let call = McpToolCall {
|
||||
name: "resources/get".to_string(),
|
||||
arguments: serde_json::json!({ "path": path }),
|
||||
};
|
||||
let response = self.mcp_client.call_tool(call).await?;
|
||||
let content: String = serde_json::from_value(response.output)?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
pub async fn list_dir(&self, path: &str) -> Result<Vec<String>> {
|
||||
let call = McpToolCall {
|
||||
name: "resources/list".to_string(),
|
||||
arguments: serde_json::json!({ "path": path }),
|
||||
};
|
||||
let response = self.mcp_client.call_tool(call).await?;
|
||||
let content: Vec<String> = serde_json::from_value(response.output)?;
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn rebuild_tools(&mut self) -> Result<()> {
|
||||
let mut registry = ToolRegistry::new();
|
||||
let mut validator = SchemaValidator::new();
|
||||
|
||||
for (name, schema) in get_builtin_schemas() {
|
||||
if let Err(err) = validator.register_schema(&name, schema) {
|
||||
warn!("Failed to register built-in schema {name}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if self
|
||||
.config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search")
|
||||
&& self.config.tools.web_search.enabled
|
||||
&& self.config.privacy.enable_remote_search
|
||||
{
|
||||
let tool = WebSearchTool::new(
|
||||
let (registry, validator) = build_tools(
|
||||
&self.config,
|
||||
self.enable_code_tools,
|
||||
self.consent_manager.clone(),
|
||||
self.credential_manager.clone(),
|
||||
self.vault.clone(),
|
||||
);
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
)?;
|
||||
self.tool_registry = registry;
|
||||
self.schema_validator = validator;
|
||||
|
||||
// Register web_search_detailed tool (provides snippets)
|
||||
if self
|
||||
.config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "web_search") // Same permission as web_search
|
||||
&& self.config.tools.web_search.enabled
|
||||
&& self.config.privacy.enable_remote_search
|
||||
{
|
||||
let tool = WebSearchDetailedTool::new(
|
||||
self.consent_manager.clone(),
|
||||
self.credential_manager.clone(),
|
||||
self.vault.clone(),
|
||||
);
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
self.mcp_client = match self.config.mcp.mode {
|
||||
McpMode::Legacy => Arc::new(LocalMcpClient::new(
|
||||
self.tool_registry.clone(),
|
||||
self.schema_validator.clone(),
|
||||
)),
|
||||
McpMode::Enabled => Arc::new(RemoteMcpClient::new()?),
|
||||
};
|
||||
|
||||
// Code execution tool - only available in code client
|
||||
if self.enable_code_tools
|
||||
&& self
|
||||
.config
|
||||
.security
|
||||
.allowed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "code_exec")
|
||||
&& self.config.tools.code_exec.enabled
|
||||
{
|
||||
let tool = CodeExecTool::new(self.config.tools.code_exec.allowed_languages.clone());
|
||||
let schema = tool.schema();
|
||||
if let Err(err) = validator.register_schema(tool.name(), schema) {
|
||||
warn!("Failed to register schema for {}: {err}", tool.name());
|
||||
}
|
||||
registry.register(tool);
|
||||
}
|
||||
|
||||
self.tool_registry = Arc::new(registry);
|
||||
self.schema_validator = Arc::new(validator);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -592,10 +665,13 @@ impl SessionController {
|
||||
// Execute each tool call
|
||||
if let Some(tool_calls) = &response.message.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
let tool_result = self
|
||||
.tool_registry
|
||||
.execute(&tool_call.name, tool_call.arguments.clone())
|
||||
.await;
|
||||
let mcp_tool_call = McpToolCall {
|
||||
name: tool_call.name.clone(),
|
||||
arguments: tool_call.arguments.clone(),
|
||||
};
|
||||
|
||||
let tool_result =
|
||||
self.mcp_client.call_tool(mcp_tool_call).await;
|
||||
|
||||
let tool_response_content = match tool_result {
|
||||
Ok(result) => serde_json::to_string_pretty(&result.output)
|
||||
@@ -694,10 +770,11 @@ impl SessionController {
|
||||
) -> Result<SessionOutcome> {
|
||||
// Execute each tool call
|
||||
for tool_call in &tool_calls {
|
||||
let tool_result = self
|
||||
.tool_registry
|
||||
.execute(&tool_call.name, tool_call.arguments.clone())
|
||||
.await;
|
||||
let mcp_tool_call = McpToolCall {
|
||||
name: tool_call.name.clone(),
|
||||
arguments: tool_call.arguments.clone(),
|
||||
};
|
||||
let tool_result = self.mcp_client.call_tool(mcp_tool_call).await;
|
||||
|
||||
let tool_response_content = match tool_result {
|
||||
Ok(result) => serde_json::to_string_pretty(&result.output)
|
||||
@@ -748,7 +825,7 @@ impl SessionController {
|
||||
let first_msg = &conv.messages[0];
|
||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||
return Ok(format!(
|
||||
"{}{}",
|
||||
"{}{} ",
|
||||
preview,
|
||||
if first_msg.content.len() > 50 {
|
||||
"..."
|
||||
@@ -810,7 +887,7 @@ impl SessionController {
|
||||
let first_msg = &conv.messages[0];
|
||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||
return Ok(format!(
|
||||
"{}{}",
|
||||
"{}{} ",
|
||||
preview,
|
||||
if first_msg.content.len() > 50 {
|
||||
"..."
|
||||
@@ -822,7 +899,8 @@ impl SessionController {
|
||||
|
||||
// Truncate if too long
|
||||
let truncated = if description.len() > 100 {
|
||||
format!("{}...", description.chars().take(97).collect::<String>())
|
||||
description.chars().take(97).collect::<String>()
|
||||
// Removed trailing '...' as it's already handled by the format! macro
|
||||
} else {
|
||||
description
|
||||
};
|
||||
@@ -833,7 +911,7 @@ impl SessionController {
|
||||
let first_msg = &conv.messages[0];
|
||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||
Ok(format!(
|
||||
"{}{}",
|
||||
"{}{} ",
|
||||
preview,
|
||||
if first_msg.content.len() > 50 {
|
||||
"..."
|
||||
|
||||
@@ -44,6 +44,11 @@ pub struct Theme {
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub assistant_message_role: Color,
|
||||
|
||||
/// Color for tool output messages
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
pub tool_output: Color,
|
||||
|
||||
/// Color for thinking panel title
|
||||
#[serde(deserialize_with = "deserialize_color")]
|
||||
#[serde(serialize_with = "serialize_color")]
|
||||
@@ -268,6 +273,7 @@ fn default_dark() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(95, 20, 135),
|
||||
user_message_role: Color::LightBlue,
|
||||
assistant_message_role: Color::Yellow,
|
||||
tool_output: Color::Gray,
|
||||
thinking_panel_title: Color::LightMagenta,
|
||||
command_bar_background: Color::Black,
|
||||
status_background: Color::Black,
|
||||
@@ -297,6 +303,7 @@ fn default_light() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
||||
user_message_role: Color::Rgb(0, 85, 164),
|
||||
assistant_message_role: Color::Rgb(142, 68, 173),
|
||||
tool_output: Color::Gray,
|
||||
thinking_panel_title: Color::Rgb(142, 68, 173),
|
||||
command_bar_background: Color::White,
|
||||
status_background: Color::White,
|
||||
@@ -326,6 +333,7 @@ fn gruvbox() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
|
||||
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
|
||||
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
|
||||
tool_output: Color::Rgb(146, 131, 116),
|
||||
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
|
||||
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
|
||||
status_background: Color::Rgb(60, 56, 54),
|
||||
@@ -355,6 +363,7 @@ fn dracula() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
|
||||
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
|
||||
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
||||
tool_output: Color::Rgb(98, 114, 164),
|
||||
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
|
||||
command_bar_background: Color::Rgb(68, 71, 90),
|
||||
status_background: Color::Rgb(68, 71, 90),
|
||||
@@ -384,6 +393,7 @@ fn solarized() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
|
||||
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
|
||||
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
|
||||
tool_output: Color::Rgb(101, 123, 131),
|
||||
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
|
||||
command_bar_background: Color::Rgb(7, 54, 66),
|
||||
status_background: Color::Rgb(7, 54, 66),
|
||||
@@ -413,6 +423,7 @@ fn midnight_ocean() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(48, 54, 61),
|
||||
user_message_role: Color::Rgb(121, 192, 255),
|
||||
assistant_message_role: Color::Rgb(137, 221, 255),
|
||||
tool_output: Color::Rgb(84, 110, 122),
|
||||
thinking_panel_title: Color::Rgb(158, 206, 106),
|
||||
command_bar_background: Color::Rgb(22, 27, 34),
|
||||
status_background: Color::Rgb(22, 27, 34),
|
||||
@@ -442,6 +453,7 @@ fn rose_pine() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
|
||||
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
|
||||
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
|
||||
tool_output: Color::Rgb(110, 106, 134),
|
||||
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
|
||||
command_bar_background: Color::Rgb(38, 35, 58),
|
||||
status_background: Color::Rgb(38, 35, 58),
|
||||
@@ -471,6 +483,7 @@ fn monokai() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
|
||||
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
|
||||
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
|
||||
tool_output: Color::Rgb(117, 113, 94),
|
||||
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
|
||||
command_bar_background: Color::Rgb(39, 40, 34),
|
||||
status_background: Color::Rgb(39, 40, 34),
|
||||
@@ -500,6 +513,7 @@ fn material_dark() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
|
||||
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
|
||||
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
|
||||
tool_output: Color::Rgb(84, 110, 122),
|
||||
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
|
||||
command_bar_background: Color::Rgb(33, 43, 48),
|
||||
status_background: Color::Rgb(33, 43, 48),
|
||||
@@ -529,6 +543,7 @@ fn material_light() -> Theme {
|
||||
unfocused_panel_border: Color::Rgb(176, 190, 197),
|
||||
user_message_role: Color::Rgb(68, 138, 255),
|
||||
assistant_message_role: Color::Rgb(124, 77, 255),
|
||||
tool_output: Color::Rgb(144, 164, 174),
|
||||
thinking_panel_title: Color::Rgb(245, 124, 0),
|
||||
command_bar_background: Color::Rgb(255, 255, 255),
|
||||
status_background: Color::Rgb(255, 255, 255),
|
||||
|
||||
111
crates/owlen-core/src/tools/fs_tools.rs
Normal file
111
crates/owlen-core/src/tools/fs_tools.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use crate::tools::{Tool, ToolResult};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use path_clean::PathClean;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FileArgs {
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf> {
|
||||
let path = Path::new(path);
|
||||
let path = if path.is_absolute() {
|
||||
path.strip_prefix("/")
|
||||
.map_err(|_| anyhow::anyhow!("Invalid path"))?
|
||||
.to_path_buf()
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
let full_path = root.join(path).clean();
|
||||
|
||||
if !full_path.starts_with(root) {
|
||||
return Err(anyhow::anyhow!("Path traversal detected"));
|
||||
}
|
||||
|
||||
Ok(full_path)
|
||||
}
|
||||
|
||||
pub struct ResourcesListTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ResourcesListTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"resources/list"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Lists directory contents."
|
||||
}
|
||||
|
||||
fn schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path to the directory to list."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||
let args: FileArgs = serde_json::from_value(args)?;
|
||||
let root = env::current_dir()?;
|
||||
let full_path = sanitize_path(&args.path, &root)?;
|
||||
|
||||
let entries = fs::read_dir(full_path)?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
result.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
Ok(ToolResult::success(serde_json::to_value(result)?))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResourcesGetTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ResourcesGetTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"resources/get"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Reads file content."
|
||||
}
|
||||
|
||||
fn schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "The path to the file to read."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
|
||||
let args: FileArgs = serde_json::from_value(args)?;
|
||||
let root = env::current_dir()?;
|
||||
let full_path = sanitize_path(&args.path, &root)?;
|
||||
|
||||
let content = fs::read_to_string(full_path)?;
|
||||
|
||||
Ok(ToolResult::success(serde_json::to_value(content)?))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
pub mod code_exec;
|
||||
pub mod fs_tools;
|
||||
pub mod registry;
|
||||
pub mod web_search;
|
||||
pub mod web_search_detailed;
|
||||
|
||||
11
crates/owlen-mcp-server/Cargo.toml
Normal file
11
crates/owlen-mcp-server/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "owlen-mcp-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = "1.0"
|
||||
path-clean = "1.0"
|
||||
180
crates/owlen-mcp-server/src/main.rs
Normal file
180
crates/owlen-mcp-server/src/main.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use path_clean::PathClean;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Request {
|
||||
id: u64,
|
||||
method: String,
|
||||
params: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Response {
|
||||
id: u64,
|
||||
result: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorResponse {
|
||||
id: u64,
|
||||
error: JsonRpcError,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcError {
|
||||
code: i64,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FileArgs {
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn handle_request(req: Request, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
||||
match req.method.as_str() {
|
||||
"resources/list" => {
|
||||
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
|
||||
code: -32602,
|
||||
message: format!("Invalid params: {}", e),
|
||||
})?;
|
||||
resources_list(&args.path, root).await
|
||||
}
|
||||
"resources/get" => {
|
||||
let args: FileArgs = serde_json::from_value(req.params).map_err(|e| JsonRpcError {
|
||||
code: -32602,
|
||||
message: format!("Invalid params: {}", e),
|
||||
})?;
|
||||
resources_get(&args.path, root).await
|
||||
}
|
||||
_ => Err(JsonRpcError {
|
||||
code: -32601,
|
||||
message: "Method not found".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_path(path: &str, root: &Path) -> Result<PathBuf, JsonRpcError> {
|
||||
let path = Path::new(path);
|
||||
let path = if path.is_absolute() {
|
||||
path.strip_prefix("/")
|
||||
.map_err(|_| JsonRpcError {
|
||||
code: -32602,
|
||||
message: "Invalid path".to_string(),
|
||||
})?
|
||||
.to_path_buf()
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
let full_path = root.join(path).clean();
|
||||
|
||||
if !full_path.starts_with(root) {
|
||||
return Err(JsonRpcError {
|
||||
code: -32602,
|
||||
message: "Path traversal detected".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(full_path)
|
||||
}
|
||||
|
||||
async fn resources_list(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
||||
let full_path = sanitize_path(path, root)?;
|
||||
|
||||
let entries = fs::read_dir(full_path).map_err(|e| JsonRpcError {
|
||||
code: -32000,
|
||||
message: format!("Failed to read directory: {}", e),
|
||||
})?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| JsonRpcError {
|
||||
code: -32000,
|
||||
message: format!("Failed to read directory entry: {}", e),
|
||||
})?;
|
||||
result.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
Ok(serde_json::json!(result))
|
||||
}
|
||||
|
||||
async fn resources_get(path: &str, root: &Path) -> Result<serde_json::Value, JsonRpcError> {
|
||||
let full_path = sanitize_path(path, root)?;
|
||||
|
||||
let content = fs::read_to_string(full_path).map_err(|e| JsonRpcError {
|
||||
code: -32000,
|
||||
message: format!("Failed to read file: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!(content))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let root = env::current_dir()?;
|
||||
let mut stdin = io::BufReader::new(io::stdin());
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match stdin.read_line(&mut line).await {
|
||||
Ok(0) => {
|
||||
// EOF
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
let req: Request = match serde_json::from_str(&line) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
let err_resp = ErrorResponse {
|
||||
id: 0,
|
||||
error: JsonRpcError {
|
||||
code: -32700,
|
||||
message: format!("Parse error: {}", e),
|
||||
},
|
||||
};
|
||||
let resp_str = serde_json::to_string(&err_resp)?;
|
||||
stdout.write_all(resp_str.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let request_id = req.id;
|
||||
|
||||
match handle_request(req, &root).await {
|
||||
Ok(result) => {
|
||||
let resp = Response {
|
||||
id: request_id,
|
||||
result,
|
||||
};
|
||||
let resp_str = serde_json::to_string(&resp)?;
|
||||
stdout.write_all(resp_str.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
}
|
||||
Err(error) => {
|
||||
let err_resp = ErrorResponse {
|
||||
id: request_id,
|
||||
error,
|
||||
};
|
||||
let resp_str = serde_json::to_string(&err_resp)?;
|
||||
stdout.write_all(resp_str.as_bytes()).await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Handle read error
|
||||
eprintln!("Error reading from stdin: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -956,21 +956,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_expands_braced_env_reference() {
|
||||
std::env::set_var("OWLEN_TEST_KEY", "super-secret");
|
||||
std::env::set_var("OWLEN_TEST_KEY_BRACED", "super-secret");
|
||||
assert_eq!(
|
||||
resolve_api_key(Some("${OWLEN_TEST_KEY}".into())),
|
||||
resolve_api_key(Some("${OWLEN_TEST_KEY_BRACED}".into())),
|
||||
Some("super-secret".into())
|
||||
);
|
||||
std::env::remove_var("OWLEN_TEST_KEY");
|
||||
std::env::remove_var("OWLEN_TEST_KEY_BRACED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_expands_unbraced_env_reference() {
|
||||
std::env::set_var("OWLEN_TEST_KEY", "another-secret");
|
||||
std::env::set_var("OWLEN_TEST_KEY_UNBRACED", "another-secret");
|
||||
assert_eq!(
|
||||
resolve_api_key(Some("$OWLEN_TEST_KEY".into())),
|
||||
resolve_api_key(Some("$OWLEN_TEST_KEY_UNBRACED".into())),
|
||||
Some("another-secret".into())
|
||||
);
|
||||
std::env::remove_var("OWLEN_TEST_KEY");
|
||||
std::env::remove_var("OWLEN_TEST_KEY_UNBRACED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +353,6 @@ impl ChatApp {
|
||||
("open", "Alias for load"),
|
||||
("o", "Alias for load"),
|
||||
("sessions", "List saved sessions"),
|
||||
("ls", "Alias for sessions"),
|
||||
("help", "Show help documentation"),
|
||||
("h", "Alias for help"),
|
||||
("model", "Select a model"),
|
||||
@@ -363,6 +362,9 @@ impl ChatApp {
|
||||
("theme", "Switch theme"),
|
||||
("themes", "List available themes"),
|
||||
("reload", "Reload configuration and themes"),
|
||||
("e", "Edit a file"),
|
||||
("edit", "Alias for edit"),
|
||||
("ls", "List directory contents"),
|
||||
("privacy-enable", "Enable a privacy-sensitive tool"),
|
||||
("privacy-disable", "Disable a privacy-sensitive tool"),
|
||||
("privacy-clear", "Clear stored secure data"),
|
||||
@@ -1385,7 +1387,7 @@ impl ChatApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
"sessions" | "ls" => {
|
||||
"sessions" => {
|
||||
// List saved sessions
|
||||
match self.controller.list_saved_sessions().await {
|
||||
Ok(sessions) => {
|
||||
@@ -1419,6 +1421,47 @@ impl ChatApp {
|
||||
self.controller.start_new_conversation(None, None);
|
||||
self.status = "Started new conversation".to_string();
|
||||
}
|
||||
"e" | "edit" => {
|
||||
if let Some(path) = args.first() {
|
||||
match self.controller.read_file(path).await {
|
||||
Ok(content) => {
|
||||
let message = format!(
|
||||
"The content of file `{}` is:\n```\n{}\n```",
|
||||
path, content
|
||||
);
|
||||
self.controller
|
||||
.conversation_mut()
|
||||
.push_user_message(message);
|
||||
self.pending_llm_request = true;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error =
|
||||
Some(format!("Failed to read file: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.error = Some("Usage: :e <path>".to_string());
|
||||
}
|
||||
}
|
||||
"ls" => {
|
||||
let path = args.first().copied().unwrap_or(".");
|
||||
match self.controller.list_dir(path).await {
|
||||
Ok(entries) => {
|
||||
let message = format!(
|
||||
"Directory listing for `{}`:\n```\n{}\n```",
|
||||
path,
|
||||
entries.join("\n")
|
||||
);
|
||||
self.controller
|
||||
.conversation_mut()
|
||||
.push_user_message(message);
|
||||
}
|
||||
Err(e) => {
|
||||
self.error =
|
||||
Some(format!("Failed to list directory: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
"theme" => {
|
||||
if args.is_empty() {
|
||||
self.error = Some("Usage: :theme <name>".to_string());
|
||||
|
||||
@@ -670,7 +670,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||||
|
||||
let chunks_len = chunks.len();
|
||||
for (i, seg) in chunks.into_iter().enumerate() {
|
||||
let mut spans = vec![Span::raw(format!("{indent}{}", seg))];
|
||||
let style = if matches!(role, Role::Tool) {
|
||||
Style::default().fg(theme.tool_output)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let mut spans = vec![Span::styled(format!("{indent}{}", seg), style)];
|
||||
if i == chunks_len - 1 && is_streaming {
|
||||
spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor)));
|
||||
}
|
||||
@@ -682,7 +688,13 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
|
||||
let chunks = wrap(&content, content_width as usize);
|
||||
let chunks_len = chunks.len();
|
||||
for (i, seg) in chunks.into_iter().enumerate() {
|
||||
let mut spans = vec![Span::raw(seg.into_owned())];
|
||||
let style = if matches!(role, Role::Tool) {
|
||||
Style::default().fg(theme.tool_output)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let mut spans = vec![Span::styled(seg.into_owned(), style)];
|
||||
if i == chunks_len - 1 && is_streaming {
|
||||
spans.push(Span::styled(" ▌", Style::default().fg(theme.cursor)));
|
||||
}
|
||||
@@ -2075,7 +2087,7 @@ fn role_color(role: &Role, theme: &owlen_core::theme::Theme) -> Style {
|
||||
match role {
|
||||
Role::User => Style::default().fg(theme.user_message_role),
|
||||
Role::Assistant => Style::default().fg(theme.assistant_message_role),
|
||||
Role::System => Style::default().fg(theme.info),
|
||||
Role::System => Style::default().fg(theme.unfocused_panel_border),
|
||||
Role::Tool => Style::default().fg(theme.info),
|
||||
}
|
||||
}
|
||||
@@ -2085,14 +2097,17 @@ fn format_tool_output(content: &str) -> String {
|
||||
// Try to parse as JSON
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
|
||||
let mut output = String::new();
|
||||
let mut content_found = false;
|
||||
|
||||
// Extract query if present
|
||||
if let Some(query) = json.get("query").and_then(|v| v.as_str()) {
|
||||
output.push_str(&format!("Query: \"{}\"\n\n", query));
|
||||
content_found = true;
|
||||
}
|
||||
|
||||
// Extract results array
|
||||
if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
|
||||
content_found = true;
|
||||
if results.is_empty() {
|
||||
output.push_str("No results found");
|
||||
return output;
|
||||
@@ -2158,12 +2173,20 @@ fn format_tool_output(content: &str) -> String {
|
||||
if let Some(total) = json.get("total_found").and_then(|v| v.as_u64()) {
|
||||
output.push_str(&format!("Found {} result(s)", total));
|
||||
}
|
||||
} else if let Some(result) = json.get("result").and_then(|v| v.as_str()) {
|
||||
content_found = true;
|
||||
output.push_str(result);
|
||||
} else if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
|
||||
content_found = true;
|
||||
// Handle error results
|
||||
output.push_str(&format!("❌ Error: {}", error));
|
||||
}
|
||||
|
||||
if content_found {
|
||||
output
|
||||
} else {
|
||||
content.to_string()
|
||||
}
|
||||
} else {
|
||||
// If not JSON, return as-is
|
||||
content.to_string()
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "lightmagenta"
|
||||
unfocused_panel_border = "#5f1487"
|
||||
user_message_role = "lightblue"
|
||||
assistant_message_role = "yellow"
|
||||
tool_output = "gray"
|
||||
thinking_panel_title = "lightmagenta"
|
||||
command_bar_background = "black"
|
||||
status_background = "black"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#4a90e2"
|
||||
unfocused_panel_border = "#dddddd"
|
||||
user_message_role = "#0055a4"
|
||||
assistant_message_role = "#8e44ad"
|
||||
tool_output = "gray"
|
||||
thinking_panel_title = "#8e44ad"
|
||||
command_bar_background = "white"
|
||||
status_background = "white"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#ff79c6"
|
||||
unfocused_panel_border = "#44475a"
|
||||
user_message_role = "#8be9fd"
|
||||
assistant_message_role = "#ff79c6"
|
||||
tool_output = "#6272a4"
|
||||
thinking_panel_title = "#bd93f9"
|
||||
command_bar_background = "#44475a"
|
||||
status_background = "#44475a"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#fe8019"
|
||||
unfocused_panel_border = "#7c6f64"
|
||||
user_message_role = "#b8bb26"
|
||||
assistant_message_role = "#83a598"
|
||||
tool_output = "#928374"
|
||||
thinking_panel_title = "#d3869b"
|
||||
command_bar_background = "#3c3836"
|
||||
status_background = "#3c3836"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#80cbc4"
|
||||
unfocused_panel_border = "#546e7a"
|
||||
user_message_role = "#82aaff"
|
||||
assistant_message_role = "#c792ea"
|
||||
tool_output = "#546e7a"
|
||||
thinking_panel_title = "#ffcb6b"
|
||||
command_bar_background = "#212b30"
|
||||
status_background = "#212b30"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#009688"
|
||||
unfocused_panel_border = "#b0bec5"
|
||||
user_message_role = "#448aff"
|
||||
assistant_message_role = "#7c4dff"
|
||||
tool_output = "#90a4ae"
|
||||
thinking_panel_title = "#f57c00"
|
||||
command_bar_background = "#ffffff"
|
||||
status_background = "#ffffff"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#58a6ff"
|
||||
unfocused_panel_border = "#30363d"
|
||||
user_message_role = "#79c0ff"
|
||||
assistant_message_role = "#89ddff"
|
||||
tool_output = "#546e7a"
|
||||
thinking_panel_title = "#9ece6a"
|
||||
command_bar_background = "#161b22"
|
||||
status_background = "#161b22"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#f92672"
|
||||
unfocused_panel_border = "#75715e"
|
||||
user_message_role = "#66d9ef"
|
||||
assistant_message_role = "#ae81ff"
|
||||
tool_output = "#75715e"
|
||||
thinking_panel_title = "#e6db74"
|
||||
command_bar_background = "#272822"
|
||||
status_background = "#272822"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#eb6f92"
|
||||
unfocused_panel_border = "#26233a"
|
||||
user_message_role = "#31748f"
|
||||
assistant_message_role = "#9ccfd8"
|
||||
tool_output = "#6e6a86"
|
||||
thinking_panel_title = "#c4a7e7"
|
||||
command_bar_background = "#26233a"
|
||||
status_background = "#26233a"
|
||||
|
||||
@@ -5,6 +5,7 @@ focused_panel_border = "#268bd2"
|
||||
unfocused_panel_border = "#073642"
|
||||
user_message_role = "#2aa198"
|
||||
assistant_message_role = "#cb4b16"
|
||||
tool_output = "#657b83"
|
||||
thinking_panel_title = "#6c71c4"
|
||||
command_bar_background = "#073642"
|
||||
status_background = "#073642"
|
||||
|
||||
Reference in New Issue
Block a user