mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* feat: add initial project structure and documentation - Introduced the GNU General Public License (GPL) v3 in LICENSE file. - Added MCP configuration in .claude/mcp.json for server integration. - Created architecture documentation in docs/ARCHITECTURE.md outlining the platform's design and components. - Defined MVP specifications in docs/MVP.md for the Telegram-based Agent Assistant. - Established API reference for team management in docs/teams-api-reference.md. - Set up basic HTML structure in public/index.html and added logo image in public/logo.png. * feat: add initial project documentation and HTML structure - Introduced CODE_OF_CONDUCT.md to establish community guidelines and standards for behavior. - Created CONTRIBUTING.md to outline contribution process, development setup, and project conventions. - Added SECURITY.md to define the security policy, supported versions, and reporting procedures for vulnerabilities. - Established basic HTML structure in index.html for the application interface. * chore: remove hello-python skill files - Deleted skill.json and skill.py files for the Hello Python example runtime skill, as they are no longer needed in the project. * feat: port tinyhuman agent runtime from ZeroClaw into Tauri backend Port daemon supervisor, health registry, security (policy, secrets, audit, pairing), agent traits, and config modules from ZeroClaw (MIT) into a new tinyhuman/ module under src-tauri/src/. The daemon auto-starts on desktop and shuts down gracefully on app exit via CancellationToken. - health: global HealthRegistry with component tracking and JSON snapshots - security/policy: SecurityPolicy with command validation, risk levels, rate limiting - security/secrets: ChaCha20-Poly1305 SecretStore with legacy XOR migration - security/audit: AuditLogger with JSON-line events and log rotation - security/pairing: PairingGuard with brute-force protection and SHA-256 hashing - security/traits: Sandbox trait + NoopSandbox - config: minimal DaemonConfig with autonomy, reliability, secrets, audit sub-configs - daemon: supervisor with health state writer emitting Tauri events - agent/traits: Provider, Tool, Memory, Observer, RuntimeAdapter traits + Noop impls - commands/tinyhuman: Tauri commands for health, security policy, encrypt/decrypt - 185 inline unit tests across all modules - README updated with custom inference/tunneling/memory positioning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update README to reflect AlphaHuman Mk1 branding and enhanced description - Changed project title to "AlphaHuman Mk1" for clarity. - Revised project description to emphasize user-friendly AI capabilities and the use of the Neocortex Mk1 model. - Removed outdated sections on custom inference, tunneling, and memory, streamlining the content for better readability. * update readme * Port zeroclaw runtime into tinyhuman * Replace CLI mentions with UI language * Split gateway module into smaller units * Split channels and config schema modules * Fix tinyhuman build, tests, and tunnel integration * feat(tinyhuman): add missing modules and ui-friendly services * refactor: rename tinyhuman to alphahuman * chore: remove bottom text from Welcome component * feat(settings): add tauri command console * feat(daemon): enhance daemon mode handling and integrate rustls with ring feature * feat(settings): implement comprehensive configuration management in TauriCommandsPanel * refactor(TauriCommandsPanel): streamline error handling and enhance async function usage * feat(settings): add skill management functionality to TauriCommandsPanel * style(TauriCommandsPanel): update input styles for improved readability and user experience * feat(settings): add Skills and Agent Chat panels with navigation and integration management * feat(settings): implement browser access management in SkillsPanel and enhance AgentChatPanel with local storage functionality --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
332 lines
10 KiB
Rust
332 lines
10 KiB
Rust
//! Per-sender routing and runtime command handling.
|
|
|
|
use super::context::{
|
|
clear_sender_history, conversation_history_key, ChannelRouteSelection, ChannelRuntimeContext,
|
|
};
|
|
use super::traits;
|
|
use super::{Channel, SendMessage};
|
|
use crate::alphahuman::providers::{self, Provider};
|
|
use serde::Deserialize;
|
|
use std::fmt::Write;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
const MODEL_CACHE_FILE: &str = "models_cache.json";
|
|
const MODEL_CACHE_PREVIEW_LIMIT: usize = 10;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum ChannelRuntimeCommand {
|
|
ShowProviders,
|
|
SetProvider(String),
|
|
ShowModel,
|
|
SetModel(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
struct ModelCacheState {
|
|
entries: Vec<ModelCacheEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
struct ModelCacheEntry {
|
|
provider: String,
|
|
models: Vec<String>,
|
|
}
|
|
|
|
fn supports_runtime_model_switch(channel_name: &str) -> bool {
|
|
matches!(channel_name, "telegram" | "discord")
|
|
}
|
|
|
|
fn parse_runtime_command(channel_name: &str, content: &str) -> Option<ChannelRuntimeCommand> {
|
|
if !supports_runtime_model_switch(channel_name) {
|
|
return None;
|
|
}
|
|
|
|
let trimmed = content.trim();
|
|
if !trimmed.starts_with('/') {
|
|
return None;
|
|
}
|
|
|
|
let mut parts = trimmed.split_whitespace();
|
|
let command_token = parts.next()?;
|
|
let base_command = command_token
|
|
.split('@')
|
|
.next()
|
|
.unwrap_or(command_token)
|
|
.to_ascii_lowercase();
|
|
|
|
match base_command.as_str() {
|
|
"/models" => {
|
|
if let Some(provider) = parts.next() {
|
|
Some(ChannelRuntimeCommand::SetProvider(
|
|
provider.trim().to_string(),
|
|
))
|
|
} else {
|
|
Some(ChannelRuntimeCommand::ShowProviders)
|
|
}
|
|
}
|
|
"/model" => {
|
|
let model = parts.collect::<Vec<_>>().join(" ").trim().to_string();
|
|
if model.is_empty() {
|
|
Some(ChannelRuntimeCommand::ShowModel)
|
|
} else {
|
|
Some(ChannelRuntimeCommand::SetModel(model))
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn resolve_provider_alias(name: &str) -> Option<String> {
|
|
let candidate = name.trim();
|
|
if candidate.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let providers_list = providers::list_providers();
|
|
for provider in providers_list {
|
|
if provider.name.eq_ignore_ascii_case(candidate)
|
|
|| provider
|
|
.aliases
|
|
.iter()
|
|
.any(|alias| alias.eq_ignore_ascii_case(candidate))
|
|
{
|
|
return Some(provider.name.to_string());
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn default_route_selection(ctx: &ChannelRuntimeContext) -> ChannelRouteSelection {
|
|
ChannelRouteSelection {
|
|
provider: ctx.default_provider.as_str().to_string(),
|
|
model: ctx.model.as_str().to_string(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn get_route_selection(
|
|
ctx: &ChannelRuntimeContext,
|
|
sender_key: &str,
|
|
) -> ChannelRouteSelection {
|
|
ctx.route_overrides
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.get(sender_key)
|
|
.cloned()
|
|
.unwrap_or_else(|| default_route_selection(ctx))
|
|
}
|
|
|
|
fn set_route_selection(ctx: &ChannelRuntimeContext, sender_key: &str, next: ChannelRouteSelection) {
|
|
let default_route = default_route_selection(ctx);
|
|
let mut routes = ctx
|
|
.route_overrides
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
if next == default_route {
|
|
routes.remove(sender_key);
|
|
} else {
|
|
routes.insert(sender_key.to_string(), next);
|
|
}
|
|
}
|
|
|
|
fn load_cached_model_preview(workspace_dir: &Path, provider_name: &str) -> Vec<String> {
|
|
let cache_path = workspace_dir.join("state").join(MODEL_CACHE_FILE);
|
|
let Ok(raw) = std::fs::read_to_string(cache_path) else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(state) = serde_json::from_str::<ModelCacheState>(&raw) else {
|
|
return Vec::new();
|
|
};
|
|
|
|
state
|
|
.entries
|
|
.into_iter()
|
|
.find(|entry| entry.provider == provider_name)
|
|
.map(|entry| {
|
|
entry
|
|
.models
|
|
.into_iter()
|
|
.take(MODEL_CACHE_PREVIEW_LIMIT)
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub(crate) async fn get_or_create_provider(
|
|
ctx: &ChannelRuntimeContext,
|
|
provider_name: &str,
|
|
) -> anyhow::Result<Arc<dyn Provider>> {
|
|
if provider_name == ctx.default_provider.as_str() {
|
|
return Ok(Arc::clone(&ctx.provider));
|
|
}
|
|
|
|
if let Some(existing) = ctx
|
|
.provider_cache
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.get(provider_name)
|
|
.cloned()
|
|
{
|
|
return Ok(existing);
|
|
}
|
|
|
|
let api_url = if provider_name == ctx.default_provider.as_str() {
|
|
ctx.api_url.as_deref()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let provider = providers::create_resilient_provider_with_options(
|
|
provider_name,
|
|
ctx.api_key.as_deref(),
|
|
api_url,
|
|
&ctx.reliability,
|
|
&ctx.provider_runtime_options,
|
|
)?;
|
|
let provider: Arc<dyn Provider> = Arc::from(provider);
|
|
|
|
if let Err(err) = provider.warmup().await {
|
|
tracing::warn!(provider = provider_name, "Provider warmup failed: {err}");
|
|
}
|
|
|
|
let mut cache = ctx.provider_cache.lock().unwrap_or_else(|e| e.into_inner());
|
|
let cached = cache
|
|
.entry(provider_name.to_string())
|
|
.or_insert_with(|| Arc::clone(&provider));
|
|
Ok(Arc::clone(cached))
|
|
}
|
|
|
|
fn build_models_help_response(current: &ChannelRouteSelection, workspace_dir: &Path) -> String {
|
|
let mut response = String::new();
|
|
let _ = writeln!(
|
|
response,
|
|
"Current provider: `{}`\nCurrent model: `{}`",
|
|
current.provider, current.model
|
|
);
|
|
response.push_str("\nSwitch model with `/model <model-id>`.\n");
|
|
|
|
let cached_models = load_cached_model_preview(workspace_dir, ¤t.provider);
|
|
if cached_models.is_empty() {
|
|
let _ = writeln!(
|
|
response,
|
|
"\nNo cached model list found for `{}`. Ask the operator to refresh the model list in the web UI.",
|
|
current.provider
|
|
);
|
|
} else {
|
|
let _ = writeln!(
|
|
response,
|
|
"\nCached model IDs (top {}):",
|
|
cached_models.len()
|
|
);
|
|
for model in cached_models {
|
|
let _ = writeln!(response, "- `{model}`");
|
|
}
|
|
}
|
|
|
|
response
|
|
}
|
|
|
|
fn build_providers_help_response(current: &ChannelRouteSelection) -> String {
|
|
let mut response = String::new();
|
|
let _ = writeln!(
|
|
response,
|
|
"Current provider: `{}`\nCurrent model: `{}`",
|
|
current.provider, current.model
|
|
);
|
|
response.push_str("\nSwitch provider with `/models <provider>`.\n");
|
|
response.push_str("Switch model with `/model <model-id>`.\n\n");
|
|
response.push_str("Available providers:\n");
|
|
for provider in providers::list_providers() {
|
|
if provider.aliases.is_empty() {
|
|
let _ = writeln!(response, "- {}", provider.name);
|
|
} else {
|
|
let _ = writeln!(
|
|
response,
|
|
"- {} (aliases: {})",
|
|
provider.name,
|
|
provider.aliases.join(", ")
|
|
);
|
|
}
|
|
}
|
|
response
|
|
}
|
|
|
|
pub(crate) async fn handle_runtime_command_if_needed(
|
|
ctx: &ChannelRuntimeContext,
|
|
msg: &traits::ChannelMessage,
|
|
target_channel: Option<&Arc<dyn Channel>>,
|
|
) -> bool {
|
|
let Some(command) = parse_runtime_command(&msg.channel, &msg.content) else {
|
|
return false;
|
|
};
|
|
|
|
let Some(channel) = target_channel else {
|
|
return true;
|
|
};
|
|
|
|
let sender_key = conversation_history_key(msg);
|
|
let mut current = get_route_selection(ctx, &sender_key);
|
|
|
|
let response = match command {
|
|
ChannelRuntimeCommand::ShowProviders => build_providers_help_response(¤t),
|
|
ChannelRuntimeCommand::SetProvider(raw_provider) => {
|
|
match resolve_provider_alias(&raw_provider) {
|
|
Some(provider_name) => match get_or_create_provider(ctx, &provider_name).await {
|
|
Ok(_) => {
|
|
if provider_name != current.provider {
|
|
current.provider = provider_name.clone();
|
|
set_route_selection(ctx, &sender_key, current.clone());
|
|
clear_sender_history(ctx, &sender_key);
|
|
}
|
|
|
|
format!(
|
|
"Provider switched to `{provider_name}` for this sender session. Current model is `{}`.\nUse `/model <model-id>` to set a provider-compatible model.",
|
|
current.model
|
|
)
|
|
}
|
|
Err(err) => {
|
|
let safe_err = providers::sanitize_api_error(&err.to_string());
|
|
format!(
|
|
"Failed to initialize provider `{provider_name}`. Route unchanged.\nDetails: {safe_err}"
|
|
)
|
|
}
|
|
},
|
|
None => format!(
|
|
"Unknown provider `{raw_provider}`. Use `/models` to list valid providers."
|
|
),
|
|
}
|
|
}
|
|
ChannelRuntimeCommand::ShowModel => {
|
|
build_models_help_response(¤t, ctx.workspace_dir.as_path())
|
|
}
|
|
ChannelRuntimeCommand::SetModel(raw_model) => {
|
|
let model = raw_model.trim().trim_matches('`').to_string();
|
|
if model.is_empty() {
|
|
"Model ID cannot be empty. Use `/model <model-id>`.".to_string()
|
|
} else {
|
|
current.model = model.clone();
|
|
set_route_selection(ctx, &sender_key, current.clone());
|
|
clear_sender_history(ctx, &sender_key);
|
|
|
|
format!(
|
|
"Model switched to `{model}` for provider `{}` in this sender session.",
|
|
current.provider
|
|
)
|
|
}
|
|
}
|
|
};
|
|
|
|
if let Err(err) = channel
|
|
.send(&SendMessage::new(response, &msg.reply_target).in_thread(msg.thread_ts.clone()))
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
"Failed to send runtime command response on {}: {err}",
|
|
channel.name()
|
|
);
|
|
}
|
|
|
|
true
|
|
}
|