Files
openhuman/src-tauri/src/alphahuman/channels/routes.rs
T
c2ff8b693b feat/openclaw (#128)
* 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>
2026-02-20 13:03:15 +04:00

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, &current.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(&current),
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(&current, 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
}