mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-08-14 00:47:51 +00:00
bugfixes release
This commit is contained in:
Generated
+14
-14
@@ -3866,7 +3866,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-api"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3902,7 +3902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-channels"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -3933,7 +3933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-cli"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -3960,7 +3960,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-desktop"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"open",
|
||||
@@ -3986,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-extensions"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -4014,7 +4014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-hands"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dashmap",
|
||||
@@ -4031,7 +4031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-kernel"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4067,7 +4067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-memory"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4086,7 +4086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-migrate"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 6.0.0",
|
||||
@@ -4105,7 +4105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-runtime"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4137,7 +4137,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-skills"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
@@ -4160,7 +4160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-types"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -4179,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openfang-wire"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -8791,7 +8791,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.3"
|
||||
version = "0.3.4"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
|
||||
@@ -934,30 +934,6 @@ fn parse_trigger_pattern(s: &str) -> Option<openfang_kernel::triggers::TriggerPa
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a default agent by name — find running or spawn from manifest.
|
||||
async fn resolve_default_agent(
|
||||
handle: &KernelBridgeAdapter,
|
||||
name: &str,
|
||||
router: &mut AgentRouter,
|
||||
adapter_name: &str,
|
||||
) {
|
||||
match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(agent_id)) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name} default agent: {name} ({agent_id})");
|
||||
}
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(agent_id) => {
|
||||
router.set_default(agent_id);
|
||||
info!("{adapter_name}: spawned default agent {name} ({agent_id})");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("{adapter_name}: could not find or spawn default agent '{name}': {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a token from an env var, returning None with a warning if missing/empty.
|
||||
fn read_token(env_var: &str, adapter_name: &str) -> Option<String> {
|
||||
match std::env::var(env_var) {
|
||||
@@ -1545,12 +1521,40 @@ pub async fn start_channel_bridge_with_config(
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
// Resolve default agent from first adapter that has one configured
|
||||
// Resolve per-channel default agents AND set the first one as system-wide fallback
|
||||
let mut router = AgentRouter::new();
|
||||
for (_, default_agent) in &adapters {
|
||||
let mut system_default_set = false;
|
||||
for (adapter, default_agent) in &adapters {
|
||||
if let Some(ref name) = default_agent {
|
||||
resolve_default_agent(&handle, name, &mut router, "Channel bridge").await;
|
||||
break; // Only need one default
|
||||
// Resolve agent name to ID
|
||||
let agent_id = match handle.find_agent_by_name(name).await {
|
||||
Ok(Some(id)) => Some(id),
|
||||
_ => match handle.spawn_agent_by_name(name).await {
|
||||
Ok(id) => Some(id),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"{}: could not find or spawn default agent '{}': {e}",
|
||||
adapter.name(),
|
||||
name
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(agent_id) = agent_id {
|
||||
// Register per-channel default
|
||||
let channel_key = format!("{:?}", adapter.channel_type());
|
||||
info!(
|
||||
"{} default agent: {name} ({agent_id}) [channel: {channel_key}]",
|
||||
adapter.name()
|
||||
);
|
||||
router.set_channel_default(channel_key, agent_id);
|
||||
// First configured default also becomes system-wide fallback
|
||||
if !system_default_set {
|
||||
router.set_default(agent_id);
|
||||
system_default_set = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1050,7 +1050,7 @@ pub async fn send_message_stream(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Field type for the channel configuration form.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FieldType {
|
||||
Secret,
|
||||
Text,
|
||||
@@ -1763,12 +1763,16 @@ fn is_channel_configured(config: &openfang_types::config::ChannelsConfig, name:
|
||||
}
|
||||
|
||||
/// Build a JSON field descriptor, checking env var presence but never exposing secrets.
|
||||
fn build_field_json(f: &ChannelField) -> serde_json::Value {
|
||||
/// For non-secret fields, includes the actual config value from `config_values` if available.
|
||||
fn build_field_json(
|
||||
f: &ChannelField,
|
||||
config_values: Option<&serde_json::Value>,
|
||||
) -> serde_json::Value {
|
||||
let has_value = f
|
||||
.env_var
|
||||
.map(|ev| std::env::var(ev).map(|v| !v.is_empty()).unwrap_or(false))
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({
|
||||
let mut field = serde_json::json!({
|
||||
"key": f.key,
|
||||
"label": f.label,
|
||||
"type": f.field_type.as_str(),
|
||||
@@ -1777,7 +1781,41 @@ fn build_field_json(f: &ChannelField) -> serde_json::Value {
|
||||
"has_value": has_value,
|
||||
"placeholder": f.placeholder,
|
||||
"advanced": f.advanced,
|
||||
})
|
||||
});
|
||||
// For non-secret fields, include the actual saved config value so the
|
||||
// dashboard can pre-populate forms when editing existing configs.
|
||||
if f.env_var.is_none() {
|
||||
if let Some(obj) = config_values.and_then(|v| v.as_object()) {
|
||||
if let Some(val) = obj.get(f.key) {
|
||||
// Convert arrays to comma-separated string for list fields
|
||||
let display_val = if f.field_type == FieldType::List {
|
||||
if let Some(arr) = val.as_array() {
|
||||
serde_json::Value::String(
|
||||
arr.iter()
|
||||
.filter_map(|v| {
|
||||
v.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Some(v.to_string()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
} else {
|
||||
val.clone()
|
||||
}
|
||||
} else {
|
||||
val.clone()
|
||||
};
|
||||
field["value"] = display_val;
|
||||
if !val.is_null()
|
||||
&& val.as_str().map(|s| !s.is_empty()).unwrap_or(true)
|
||||
{
|
||||
field["has_value"] = serde_json::Value::Bool(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
field
|
||||
}
|
||||
|
||||
/// Find a channel definition by name.
|
||||
@@ -1785,6 +1823,56 @@ fn find_channel_meta(name: &str) -> Option<&'static ChannelMeta> {
|
||||
CHANNEL_REGISTRY.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
/// Serialize a channel's config to a JSON Value for pre-populating dashboard forms.
|
||||
fn channel_config_values(
|
||||
config: &openfang_types::config::ChannelsConfig,
|
||||
name: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
match name {
|
||||
"telegram" => config.telegram.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discord" => config.discord.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"slack" => config.slack.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"whatsapp" => config.whatsapp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"signal" => config.signal.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"matrix" => config.matrix.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"email" => config.email.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"teams" => config.teams.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mattermost" => config.mattermost.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"irc" => config.irc.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"google_chat" => config.google_chat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twitch" => config.twitch.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"rocketchat" => config.rocketchat.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"zulip" => config.zulip.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"xmpp" => config.xmpp.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"line" => config.line.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"viber" => config.viber.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"messenger" => config.messenger.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"reddit" => config.reddit.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mastodon" => config.mastodon.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"bluesky" => config.bluesky.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"feishu" => config.feishu.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"revolt" => config.revolt.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nextcloud" => config.nextcloud.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"guilded" => config.guilded.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"keybase" => config.keybase.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"threema" => config.threema.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"nostr" => config.nostr.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webex" => config.webex.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"pumble" => config.pumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"flock" => config.flock.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"twist" => config.twist.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"mumble" => config.mumble.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"dingtalk" => config.dingtalk.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"discourse" => config.discourse.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gitter" => config.gitter.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"ntfy" => config.ntfy.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"gotify" => config.gotify.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"webhook" => config.webhook.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
"linkedin" => config.linkedin.as_ref().and_then(|c| serde_json::to_value(c).ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/channels — List all 40 channel adapters with status and field metadata.
|
||||
pub async fn list_channels(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// Read the live channels config (updated on every hot-reload) instead of the
|
||||
@@ -1810,7 +1898,12 @@ pub async fn list_channels(State(state): State<Arc<AppState>>) -> impl IntoRespo
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
let fields: Vec<serde_json::Value> = meta.fields.iter().map(build_field_json).collect();
|
||||
let config_vals = channel_config_values(&live_channels, meta.name);
|
||||
let fields: Vec<serde_json::Value> = meta
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| build_field_json(f, config_vals.as_ref()))
|
||||
.collect();
|
||||
|
||||
channels.push(serde_json::json!({
|
||||
"name": meta.name,
|
||||
|
||||
@@ -898,6 +898,121 @@ mark.search-highlight {
|
||||
.slash-menu-item:last-child { border-bottom: none; }
|
||||
.slash-menu-item:hover, .slash-menu-item.slash-active { background: var(--surface2); }
|
||||
|
||||
/* Model switcher dropdown */
|
||||
.model-switcher-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
max-width: 200px;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.model-switcher-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.model-switcher-btn:disabled:hover { border-color: var(--border); color: var(--text-dim); }
|
||||
.model-switcher-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
.model-switcher-chevron {
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.model-switcher-chevron.open { transform: rotate(180deg); }
|
||||
.model-switcher-dropdown {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
width: 340px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
.model-switcher-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.model-switcher-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.model-switcher-group-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-switcher-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.model-switcher-item:hover { background: var(--surface2); }
|
||||
.model-switcher-item.active {
|
||||
background: var(--accent-subtle, rgba(255,92,0,0.06));
|
||||
cursor: default;
|
||||
}
|
||||
.model-switcher-item-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-switcher-tier {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.model-switcher-tier.tier-frontier { background: rgba(168,85,247,0.15); color: #a855f7; }
|
||||
.model-switcher-tier.tier-smart { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
||||
.model-switcher-tier.tier-balanced { background: rgba(34,197,94,0.15); color: #22c55e; }
|
||||
.model-switcher-tier.tier-fast { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||
.model-switcher-tier.tier-local { background: rgba(148,163,184,0.12); color: var(--text-dim); }
|
||||
|
||||
/* Sidebar footer */
|
||||
.sidebar-footer {
|
||||
padding: 8px 0;
|
||||
|
||||
@@ -746,9 +746,55 @@
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Footer: tokens + queue + tips -->
|
||||
<!-- Footer: model switcher + tokens + queue + tips -->
|
||||
<div class="input-footer">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Model Switcher -->
|
||||
<div style="position:relative" x-show="currentAgent" @click.outside="showModelSwitcher = false" @keydown.escape.window="showModelSwitcher = false">
|
||||
<button class="model-switcher-btn" @click="toggleModelSwitcher()" :disabled="sending" title="Switch model (Ctrl+M)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
<span class="model-switcher-label" x-text="modelDisplayName || 'Model'"></span>
|
||||
<svg class="model-switcher-chevron" :class="{'open': showModelSwitcher}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<!-- Dropdown -->
|
||||
<div class="model-switcher-dropdown" x-show="showModelSwitcher" x-transition:enter="transition ease-out duration-150" x-transition:enter-start="opacity-0 transform translate-y-1" x-transition:enter-end="opacity-100 transform translate-y-0" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
||||
<div class="model-switcher-search">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input id="model-switcher-search" type="text" x-model="modelSwitcherFilter" placeholder="Search models..." @keydown.escape.stop="showModelSwitcher = false" @keydown.arrow-down.prevent="modelSwitcherIdx = Math.min(modelSwitcherIdx + 1, filteredSwitcherModels.length - 1)" @keydown.arrow-up.prevent="modelSwitcherIdx = Math.max(modelSwitcherIdx - 1, 0)" @keydown.enter.prevent="filteredSwitcherModels[modelSwitcherIdx] && switchModel(filteredSwitcherModels[modelSwitcherIdx])">
|
||||
</div>
|
||||
<div x-show="modelSwitching" style="display:flex;align-items:center;justify-content:center;padding:12px;gap:8px">
|
||||
<div class="tool-card-spinner"></div>
|
||||
<span class="text-xs text-dim">Switching...</span>
|
||||
</div>
|
||||
<div class="model-switcher-list" x-show="!modelSwitching">
|
||||
<template x-if="groupedSwitcherModels.length === 0">
|
||||
<div style="padding:16px;text-align:center" class="text-xs text-dim">No models found</div>
|
||||
</template>
|
||||
<template x-for="group in groupedSwitcherModels" :key="group.provider">
|
||||
<div>
|
||||
<div class="model-switcher-group-header" x-text="group.provider"></div>
|
||||
<template x-for="(m, mi) in group.models" :key="m.id">
|
||||
<div class="model-switcher-item" :class="{'active': currentAgent && m.id === currentAgent.model_name}" @click="switchModel(m)" @mouseenter="modelSwitcherIdx = filteredSwitcherModels.indexOf(m)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span class="model-switcher-item-name" x-text="m.display_name || m.id"></span>
|
||||
<span class="model-switcher-tier" :class="'tier-' + (m.tier || 'balanced').toLowerCase()" x-text="m.tier || 'Balanced'"></span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
||||
<span class="text-xs text-dim" x-text="m.id" style="font-family:var(--font-mono)"></span>
|
||||
<span class="text-xs text-dim" x-show="m.context_window" x-text="m.context_window >= 1000000 ? (m.context_window/1000000).toFixed(1)+'M' : Math.round(m.context_window/1000)+'K'"></span>
|
||||
<svg x-show="m.supports_vision" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Vision"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg x-show="m.supports_tools" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.5" title="Tools"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<svg x-show="currentAgent && m.id === currentAgent.model_name" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-dim" x-text="tokenCount > 0 ? '~' + tokenCount + ' tokens' : (attachments.length ? attachments.length + ' file(s)' : '')"></span>
|
||||
<span class="queue-badge" x-show="messageQueue.length > 0" x-text="messageQueue.length + ' queued'"></span>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +141,16 @@ function channelsPage() {
|
||||
|
||||
openSetup(ch) {
|
||||
this.setupModal = ch;
|
||||
this.formValues = {};
|
||||
// Pre-populate form values from saved config (non-secret fields).
|
||||
var vals = {};
|
||||
if (ch.fields) {
|
||||
ch.fields.forEach(function(f) {
|
||||
if (f.value !== undefined && f.value !== null && f.type !== 'secret') {
|
||||
vals[f.key] = String(f.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.formValues = vals;
|
||||
this.showAdvanced = false;
|
||||
this.showBusinessApi = false;
|
||||
this.setupStep = ch.configured ? 3 : 1;
|
||||
|
||||
@@ -34,6 +34,13 @@ function chatPage() {
|
||||
modelPickerList: [],
|
||||
modelPickerFilter: '',
|
||||
modelPickerIdx: 0,
|
||||
// Model switcher dropdown
|
||||
showModelSwitcher: false,
|
||||
modelSwitcherFilter: '',
|
||||
modelSwitcherIdx: 0,
|
||||
modelSwitching: false,
|
||||
_modelCache: null,
|
||||
_modelCacheTime: 0,
|
||||
slashCommands: [
|
||||
{ cmd: '/help', desc: 'Show available commands' },
|
||||
{ cmd: '/agents', desc: 'Switch to Agents page' },
|
||||
@@ -85,6 +92,36 @@ function chatPage() {
|
||||
}
|
||||
},
|
||||
|
||||
get modelDisplayName() {
|
||||
if (!this.currentAgent) return '';
|
||||
var name = this.currentAgent.model_name || '';
|
||||
var short = name.replace(/-\d{8}$/, '');
|
||||
return short.length > 24 ? short.substring(0, 22) + '\u2026' : short;
|
||||
},
|
||||
|
||||
get filteredSwitcherModels() {
|
||||
var models = this._modelCache || [];
|
||||
if (!this.modelSwitcherFilter) return models;
|
||||
var f = this.modelSwitcherFilter.toLowerCase();
|
||||
return models.filter(function(m) {
|
||||
return m.id.toLowerCase().indexOf(f) !== -1 ||
|
||||
(m.display_name || '').toLowerCase().indexOf(f) !== -1 ||
|
||||
m.provider.toLowerCase().indexOf(f) !== -1;
|
||||
});
|
||||
},
|
||||
|
||||
get groupedSwitcherModels() {
|
||||
var filtered = this.filteredSwitcherModels;
|
||||
var groups = {}, order = [];
|
||||
filtered.forEach(function(m) {
|
||||
if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); }
|
||||
groups[m.provider].push(m);
|
||||
});
|
||||
return order.map(function(p) {
|
||||
return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] };
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
var self = this;
|
||||
|
||||
@@ -101,6 +138,11 @@ function chatPage() {
|
||||
var input = document.getElementById('msg-input');
|
||||
if (input) { input.focus(); self.inputText = '/'; }
|
||||
}
|
||||
// Ctrl+M for model switcher
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
self.toggleModelSwitcher();
|
||||
}
|
||||
// Ctrl+F for chat search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) {
|
||||
e.preventDefault();
|
||||
@@ -172,6 +214,54 @@ function chatPage() {
|
||||
this.sendMessage();
|
||||
},
|
||||
|
||||
toggleModelSwitcher() {
|
||||
if (this.showModelSwitcher) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
var now = Date.now();
|
||||
if (this._modelCache && (now - this._modelCacheTime) < 300000) {
|
||||
this.modelSwitcherFilter = '';
|
||||
this.modelSwitcherIdx = 0;
|
||||
this.showModelSwitcher = true;
|
||||
this.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
OpenFangAPI.get('/api/models').then(function(data) {
|
||||
var models = (data.models || []).filter(function(m) { return m.available; });
|
||||
self._modelCache = models;
|
||||
self._modelCacheTime = Date.now();
|
||||
self.modelPickerList = models;
|
||||
self.modelSwitcherFilter = '';
|
||||
self.modelSwitcherIdx = 0;
|
||||
self.showModelSwitcher = true;
|
||||
self.$nextTick(function() {
|
||||
var el = document.getElementById('model-switcher-search');
|
||||
if (el) el.focus();
|
||||
});
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Failed to load models: ' + e.message);
|
||||
});
|
||||
},
|
||||
|
||||
switchModel(model) {
|
||||
if (!this.currentAgent) return;
|
||||
if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; }
|
||||
var self = this;
|
||||
this.modelSwitching = true;
|
||||
OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function() {
|
||||
self.currentAgent.model_name = model.id;
|
||||
self.currentAgent.model_provider = model.provider;
|
||||
OpenFangToast.success('Switched to ' + (model.display_name || model.id));
|
||||
self.showModelSwitcher = false;
|
||||
self.modelSwitching = false;
|
||||
}).catch(function(e) {
|
||||
OpenFangToast.error('Switch failed: ' + e.message);
|
||||
self.modelSwitching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch dynamic slash commands from server
|
||||
fetchCommands: function() {
|
||||
var self = this;
|
||||
|
||||
@@ -32,6 +32,8 @@ pub struct AgentRouter {
|
||||
direct_routes: DashMap<(String, String), AgentId>,
|
||||
/// System-wide default agent.
|
||||
default_agent: Option<AgentId>,
|
||||
/// Per-channel-type default agent (e.g., Telegram -> agent_a, Discord -> agent_b).
|
||||
channel_defaults: DashMap<String, AgentId>,
|
||||
/// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc.
|
||||
bindings: Mutex<Vec<(AgentBinding, String)>>,
|
||||
/// Broadcast configuration. Uses Mutex for runtime updates via Arc.
|
||||
@@ -47,6 +49,7 @@ impl AgentRouter {
|
||||
user_defaults: DashMap::new(),
|
||||
direct_routes: DashMap::new(),
|
||||
default_agent: None,
|
||||
channel_defaults: DashMap::new(),
|
||||
bindings: Mutex::new(Vec::new()),
|
||||
broadcast: Mutex::new(BroadcastConfig::default()),
|
||||
agent_name_cache: DashMap::new(),
|
||||
@@ -58,6 +61,11 @@ impl AgentRouter {
|
||||
self.default_agent = Some(agent_id);
|
||||
}
|
||||
|
||||
/// Set a per-channel-type default agent (e.g., "Telegram" -> agent_id).
|
||||
pub fn set_channel_default(&self, channel_key: String, agent_id: AgentId) {
|
||||
self.channel_defaults.insert(channel_key, agent_id);
|
||||
}
|
||||
|
||||
/// Set a user's default agent.
|
||||
pub fn set_user_default(&self, user_key: String, agent_id: AgentId) {
|
||||
self.user_defaults.insert(user_key, agent_id);
|
||||
@@ -125,7 +133,7 @@ impl AgentRouter {
|
||||
// 1. Check direct routes
|
||||
if let Some(agent) = self
|
||||
.direct_routes
|
||||
.get(&(channel_key, platform_user_id.to_string()))
|
||||
.get(&(channel_key.clone(), platform_user_id.to_string()))
|
||||
{
|
||||
return Some(*agent);
|
||||
}
|
||||
@@ -141,7 +149,12 @@ impl AgentRouter {
|
||||
return Some(*agent);
|
||||
}
|
||||
|
||||
// 3. System default
|
||||
// 3. Per-channel-type default
|
||||
if let Some(agent) = self.channel_defaults.get(&channel_key) {
|
||||
return Some(*agent);
|
||||
}
|
||||
|
||||
// 4. System default
|
||||
self.default_agent
|
||||
}
|
||||
|
||||
@@ -161,7 +174,7 @@ impl AgentRouter {
|
||||
let channel_key = format!("{channel_type:?}");
|
||||
if let Some(agent) = self
|
||||
.direct_routes
|
||||
.get(&(channel_key, platform_user_id.to_string()))
|
||||
.get(&(channel_key.clone(), platform_user_id.to_string()))
|
||||
{
|
||||
return Some(*agent);
|
||||
}
|
||||
@@ -173,6 +186,9 @@ impl AgentRouter {
|
||||
if let Some(agent) = self.user_defaults.get(platform_user_id) {
|
||||
return Some(*agent);
|
||||
}
|
||||
if let Some(agent) = self.channel_defaults.get(&channel_key) {
|
||||
return Some(*agent);
|
||||
}
|
||||
self.default_agent
|
||||
}
|
||||
|
||||
@@ -501,6 +517,30 @@ mod tests {
|
||||
assert_eq!(targets[1].1, Some(id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_channel_default_routing() {
|
||||
let mut router = AgentRouter::new();
|
||||
let system_default = AgentId::new();
|
||||
let telegram_default = AgentId::new();
|
||||
let discord_default = AgentId::new();
|
||||
|
||||
router.set_default(system_default);
|
||||
router.set_channel_default("Telegram".to_string(), telegram_default);
|
||||
router.set_channel_default("Discord".to_string(), discord_default);
|
||||
|
||||
// Telegram should use Telegram-specific default
|
||||
let resolved = router.resolve(&ChannelType::Telegram, "user1", None);
|
||||
assert_eq!(resolved, Some(telegram_default));
|
||||
|
||||
// Discord should use Discord-specific default
|
||||
let resolved = router.resolve(&ChannelType::Discord, "user1", None);
|
||||
assert_eq!(resolved, Some(discord_default));
|
||||
|
||||
// WhatsApp has no channel default — falls to system default
|
||||
let resolved = router.resolve(&ChannelType::WhatsApp, "user1", None);
|
||||
assert_eq!(resolved, Some(system_default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_bindings_legacy_behavior() {
|
||||
let mut router = AgentRouter::new();
|
||||
|
||||
@@ -1419,11 +1419,28 @@ fn cmd_start(config: Option<PathBuf>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Read the api_key from ~/.openfang/config.toml (if any).
|
||||
fn read_api_key() -> Option<String> {
|
||||
let config_path = dirs::home_dir()?.join(".openfang").join("config.toml");
|
||||
let text = std::fs::read_to_string(config_path).ok()?;
|
||||
let table: toml::Value = text.parse().ok()?;
|
||||
let key = table.get("api_key")?.as_str()?;
|
||||
if key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_stop() {
|
||||
match find_daemon() {
|
||||
Some(base) => {
|
||||
let client = daemon_client();
|
||||
match client.post(format!("{base}/api/shutdown")).send() {
|
||||
let mut req = client.post(format!("{base}/api/shutdown"));
|
||||
if let Some(key) = read_api_key() {
|
||||
req = req.bearer_auth(key);
|
||||
}
|
||||
match req.send() {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Wait for daemon to actually stop (up to 5 seconds)
|
||||
for _ in 0..10 {
|
||||
|
||||
@@ -220,6 +220,8 @@ impl StandaloneChat {
|
||||
}
|
||||
ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +269,13 @@ impl StandaloneChat {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent & quit",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -294,8 +297,14 @@ impl StandaloneChat {
|
||||
self.chat.push_message(Role::System, s.join("\n"));
|
||||
}
|
||||
"/model" => {
|
||||
self.chat
|
||||
.push_message(Role::System, format!("Model: {}", self.chat.model_label));
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
// No argument: open the model picker
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
// With argument: switch directly
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/clear" => {
|
||||
let name = self.chat.agent_name.clone();
|
||||
@@ -364,6 +373,160 @@ impl StandaloneChat {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model picker helpers ──────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
use super::screens::chat::ModelEntry;
|
||||
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"].as_str().unwrap_or("Balanced").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
// Skip if already on this model
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
if let Some(ref agent_id) = self.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
// Re-fetch agent to get updated provider/model
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
if let Some(id) = self.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Backend::None => {
|
||||
self.chat
|
||||
.push_message(Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Agent resolution helpers ─────────────────────────────────────────────
|
||||
|
||||
fn enter_chat_daemon(&mut self, id: String, name: String) {
|
||||
|
||||
@@ -1364,6 +1364,8 @@ impl App {
|
||||
}
|
||||
chat::ChatAction::SendMessage(msg) => self.send_message(msg),
|
||||
chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd),
|
||||
chat::ChatAction::OpenModelPicker => self.open_model_picker(),
|
||||
chat::ChatAction::SwitchModel(model_id) => self.switch_model(&model_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1848,6 +1850,159 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Model picker ────────────────────────────────────────────────────────
|
||||
|
||||
fn open_model_picker(&mut self) {
|
||||
let models = match &self.backend {
|
||||
Backend::Daemon { base_url } => {
|
||||
let client = crate::daemon_client();
|
||||
match client.get(format!("{base_url}/api/models")).send() {
|
||||
Ok(resp) => match resp.json::<serde_json::Value>() {
|
||||
Ok(body) => body["models"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter(|m| m["available"].as_bool().unwrap_or(false))
|
||||
.map(|m| chat::ModelEntry {
|
||||
id: m["id"].as_str().unwrap_or("").to_string(),
|
||||
display_name: m["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
provider: m["provider"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tier: m["tier"]
|
||||
.as_str()
|
||||
.unwrap_or("Balanced")
|
||||
.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
Backend::InProcess { kernel } => {
|
||||
let catalog = kernel.model_catalog.read().unwrap();
|
||||
catalog
|
||||
.available_models()
|
||||
.into_iter()
|
||||
.map(|e| chat::ModelEntry {
|
||||
id: e.id.clone(),
|
||||
display_name: e.display_name.clone(),
|
||||
provider: e.provider.clone(),
|
||||
tier: format!("{:?}", e.tier),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Backend::None => Vec::new(),
|
||||
};
|
||||
|
||||
if models.is_empty() {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No models available.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat.model_picker_models = models;
|
||||
self.chat.model_picker_filter.clear();
|
||||
self.chat.model_picker_idx = 0;
|
||||
self.chat.show_model_picker = true;
|
||||
}
|
||||
|
||||
fn switch_model(&mut self, model_id: &str) {
|
||||
if self.chat.model_label.ends_with(model_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
match (&self.backend, &self.chat_target) {
|
||||
(Backend::Daemon { base_url }, Some(target)) => {
|
||||
if let Some(ref agent_id) = target.agent_id_daemon {
|
||||
let client = crate::daemon_client();
|
||||
let url = format!("{base_url}/api/agents/{agent_id}/model");
|
||||
match client
|
||||
.put(&url)
|
||||
.json(&serde_json::json!({"model": model_id}))
|
||||
.send()
|
||||
{
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(resp) = client
|
||||
.get(format!("{base_url}/api/agents/{agent_id}"))
|
||||
.send()
|
||||
{
|
||||
if let Ok(body) = resp.json::<serde_json::Value>() {
|
||||
let provider =
|
||||
body["model_provider"].as_str().unwrap_or("?");
|
||||
let model = body["model_name"].as_str().unwrap_or("?");
|
||||
self.chat.model_label = format!("{provider}/{model}");
|
||||
}
|
||||
}
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Failed to switch to {model_id}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Backend::InProcess { kernel }, Some(target)) => {
|
||||
if let Some(id) = target.agent_id_inprocess {
|
||||
let provider = kernel
|
||||
.model_catalog
|
||||
.read()
|
||||
.unwrap()
|
||||
.find_model(model_id)
|
||||
.map(|e| e.provider.clone());
|
||||
let result = if let Some(ref prov) = provider {
|
||||
kernel.registry.update_model_and_provider(
|
||||
id,
|
||||
model_id.to_string(),
|
||||
prov.clone(),
|
||||
)
|
||||
} else {
|
||||
kernel.registry.update_model(id, model_id.to_string())
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let prov_label = provider.unwrap_or_else(|| {
|
||||
kernel
|
||||
.registry
|
||||
.get(id)
|
||||
.map(|e| e.manifest.model.provider.clone())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
});
|
||||
self.chat.model_label = format!("{prov_label}/{model_id}");
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switched to {model_id}"),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Switch failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.chat
|
||||
.push_message(chat::Role::System, "No backend connected.".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slash commands ──────────────────────────────────────────────────────
|
||||
|
||||
fn handle_slash_command(&mut self, cmd: &str) {
|
||||
@@ -1858,13 +2013,14 @@ impl App {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
[
|
||||
"/help \u{2014} show this help",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/model \u{2014} show current model",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
"/help \u{2014} show this help",
|
||||
"/model \u{2014} open model picker (Ctrl+M)",
|
||||
"/model <name> \u{2014} switch to model directly",
|
||||
"/status \u{2014} connection & agent info",
|
||||
"/agents \u{2014} list running agents",
|
||||
"/clear \u{2014} clear chat history",
|
||||
"/kill \u{2014} kill the current agent",
|
||||
"/exit \u{2014} end chat session",
|
||||
]
|
||||
.join("\n"),
|
||||
);
|
||||
@@ -1989,10 +2145,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
"/model" => {
|
||||
self.chat.push_message(
|
||||
chat::Role::System,
|
||||
format!("Model: {}", self.chat.model_label),
|
||||
);
|
||||
let args = parts.get(1).map(|s| s.trim()).unwrap_or("");
|
||||
if args.is_empty() {
|
||||
self.open_model_picker();
|
||||
} else {
|
||||
self.switch_model(args);
|
||||
}
|
||||
}
|
||||
"/hands" => match &self.backend {
|
||||
Backend::InProcess { kernel } => {
|
||||
|
||||
@@ -5,9 +5,18 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Padding, Paragraph};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Model entry for the picker.
|
||||
#[derive(Clone)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub provider: String,
|
||||
pub tier: String,
|
||||
}
|
||||
|
||||
/// Tool call metadata for rich rendering.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolInfo {
|
||||
@@ -68,6 +77,14 @@ pub struct ChatState {
|
||||
pub staged_messages: Vec<String>,
|
||||
/// Accumulates ToolInputDelta text for the current tool call.
|
||||
pub tool_input_buf: String,
|
||||
/// Model picker overlay state.
|
||||
pub show_model_picker: bool,
|
||||
/// Available models for the picker.
|
||||
pub model_picker_models: Vec<ModelEntry>,
|
||||
/// Filter text for model search.
|
||||
pub model_picker_filter: String,
|
||||
/// Selected index in the filtered model list.
|
||||
pub model_picker_idx: usize,
|
||||
}
|
||||
|
||||
pub enum ChatAction {
|
||||
@@ -75,6 +92,10 @@ pub enum ChatAction {
|
||||
SendMessage(String),
|
||||
Back,
|
||||
SlashCommand(String),
|
||||
/// Open the model picker (fetch models first).
|
||||
OpenModelPicker,
|
||||
/// Switch to a specific model by id.
|
||||
SwitchModel(String),
|
||||
}
|
||||
|
||||
impl ChatState {
|
||||
@@ -97,6 +118,10 @@ impl ChatState {
|
||||
status_msg: None,
|
||||
staged_messages: Vec::new(),
|
||||
tool_input_buf: String::new(),
|
||||
show_model_picker: false,
|
||||
model_picker_models: Vec::new(),
|
||||
model_picker_filter: String::new(),
|
||||
model_picker_idx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +140,9 @@ impl ChatState {
|
||||
self.status_msg = None;
|
||||
self.staged_messages.clear();
|
||||
self.tool_input_buf.clear();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
|
||||
/// Push a completed message into history.
|
||||
@@ -205,11 +233,81 @@ impl ChatState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return filtered models based on the current picker filter.
|
||||
pub fn filtered_models(&self) -> Vec<&ModelEntry> {
|
||||
if self.model_picker_filter.is_empty() {
|
||||
return self.model_picker_models.iter().collect();
|
||||
}
|
||||
let f = self.model_picker_filter.to_lowercase();
|
||||
self.model_picker_models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
m.id.to_lowercase().contains(&f)
|
||||
|| m.display_name.to_lowercase().contains(&f)
|
||||
|| m.provider.to_lowercase().contains(&f)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ChatAction {
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::Back;
|
||||
}
|
||||
|
||||
// Ctrl+M: toggle model picker
|
||||
if key.code == KeyCode::Char('m') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||
if self.is_streaming {
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
if self.show_model_picker {
|
||||
self.show_model_picker = false;
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
return ChatAction::OpenModelPicker;
|
||||
}
|
||||
|
||||
// Model picker mode: intercept all keys
|
||||
if self.show_model_picker {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.show_model_picker = false;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.model_picker_idx = self.model_picker_idx.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down => {
|
||||
let max = self.filtered_models().len().saturating_sub(1);
|
||||
if self.model_picker_idx < max {
|
||||
self.model_picker_idx += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let filtered = self.filtered_models();
|
||||
if let Some(entry) = filtered.get(self.model_picker_idx) {
|
||||
let model_id = entry.id.clone();
|
||||
self.show_model_picker = false;
|
||||
self.model_picker_filter.clear();
|
||||
self.model_picker_idx = 0;
|
||||
return ChatAction::SwitchModel(model_id);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.model_picker_filter.pop();
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.model_picker_filter.push(c);
|
||||
self.model_picker_idx = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return ChatAction::Continue;
|
||||
}
|
||||
|
||||
// When streaming, allow typing + staging messages, scrolling, and Esc
|
||||
if self.is_streaming {
|
||||
match key.code {
|
||||
@@ -361,13 +459,136 @@ pub fn draw(f: &mut Frame, area: Rect, state: &mut ChatState) {
|
||||
f.render_widget(Paragraph::new(input_line), chunks[2]);
|
||||
|
||||
// ── Hints ────────────────────────────────────────────────────────────────
|
||||
let hints = if state.is_streaming {
|
||||
let hints = if state.show_model_picker {
|
||||
" [\u{2191}\u{2193}] Navigate [Enter] Select [Esc] Close [type] Filter"
|
||||
} else if state.is_streaming {
|
||||
" [Enter] Stage [\u{2191}\u{2193}] Scroll [Esc] Stop"
|
||||
} else {
|
||||
" [Enter] Send [\u{2191}\u{2193}/PgUp/PgDn] Scroll [Esc] Back"
|
||||
" [Enter] Send [Ctrl+M] Models [\u{2191}\u{2193}] Scroll [Esc] Back"
|
||||
};
|
||||
let hints = Paragraph::new(Line::from(vec![Span::styled(hints, theme::hint_style())]));
|
||||
f.render_widget(hints, chunks[3]);
|
||||
|
||||
// ── Model picker overlay ────────────────────────────────────────────────
|
||||
if state.show_model_picker {
|
||||
draw_model_picker(f, inner, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_model_picker(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
let filtered = state.filtered_models();
|
||||
|
||||
// Center a popup — width ~50 cols, height capped at area
|
||||
if area.height < 6 || area.width < 20 {
|
||||
return; // Too small to show picker
|
||||
}
|
||||
let popup_w = area.width.clamp(30, 54);
|
||||
let popup_h = (filtered.len() as u16 + 4)
|
||||
.clamp(5, area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
|
||||
let popup_area = Rect::new(x, y, popup_w, popup_h);
|
||||
|
||||
// Clear background
|
||||
f.render_widget(Clear, popup_area);
|
||||
|
||||
let block = Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
" Switch Model ",
|
||||
theme::title_style(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme::ACCENT))
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
let inner = block.inner(popup_area);
|
||||
f.render_widget(block, popup_area);
|
||||
|
||||
if inner.height < 2 || inner.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout: search bar | model list
|
||||
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner);
|
||||
|
||||
// Search bar
|
||||
let search_line = Line::from(vec![
|
||||
Span::styled("/ ", theme::dim_style()),
|
||||
Span::raw(&state.model_picker_filter),
|
||||
Span::styled(
|
||||
"\u{2588}",
|
||||
Style::default()
|
||||
.fg(theme::ACCENT)
|
||||
.add_modifier(Modifier::SLOW_BLINK),
|
||||
),
|
||||
]);
|
||||
f.render_widget(Paragraph::new(search_line), chunks[0]);
|
||||
|
||||
// Model list
|
||||
let visible_h = chunks[1].height as usize;
|
||||
let total = filtered.len();
|
||||
|
||||
if total == 0 {
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" No models match",
|
||||
theme::dim_style(),
|
||||
)])),
|
||||
chunks[1],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll window: keep selected item visible
|
||||
let scroll_start = if state.model_picker_idx >= visible_h {
|
||||
state.model_picker_idx - visible_h + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let max_name = (chunks[1].width as usize).saturating_sub(14);
|
||||
for (i, entry) in filtered.iter().enumerate().skip(scroll_start).take(visible_h) {
|
||||
let selected = i == state.model_picker_idx;
|
||||
let indicator = if selected { "\u{25b6} " } else { " " };
|
||||
|
||||
let name = if entry.display_name.is_empty() {
|
||||
&entry.id
|
||||
} else {
|
||||
&entry.display_name
|
||||
};
|
||||
let name_display = if name.len() > max_name && max_name > 1 {
|
||||
let truncated = openfang_types::truncate_str(name, max_name.saturating_sub(1));
|
||||
format!("{truncated}\u{2026}")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let tier_style = match entry.tier.to_lowercase().as_str() {
|
||||
"frontier" => Style::default().fg(theme::PURPLE),
|
||||
"smart" => Style::default().fg(theme::BLUE),
|
||||
"balanced" => Style::default().fg(theme::GREEN),
|
||||
"fast" => Style::default().fg(theme::YELLOW),
|
||||
_ => theme::dim_style(),
|
||||
};
|
||||
|
||||
let bg = if selected {
|
||||
Style::default()
|
||||
.fg(theme::TEXT_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme::TEXT_SECONDARY)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(indicator, Style::default().fg(theme::ACCENT)),
|
||||
Span::styled(name_display, bg),
|
||||
Span::raw(" "),
|
||||
Span::styled(entry.tier.to_lowercase(), tier_style),
|
||||
]));
|
||||
}
|
||||
|
||||
f.render_widget(Paragraph::new(lines), chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_messages(f: &mut Frame, area: Rect, state: &ChatState) {
|
||||
|
||||
@@ -113,6 +113,13 @@ value = "elevenlabs"
|
||||
label = "ElevenLabs"
|
||||
provider_env = "ELEVENLABS_API_KEY"
|
||||
|
||||
[[settings]]
|
||||
key = "elevenlabs_api_key"
|
||||
label = "ElevenLabs API Key"
|
||||
description = "API key from elevenlabs.io for high-quality text-to-speech. Required when ElevenLabs TTS is selected."
|
||||
setting_type = "text"
|
||||
default = ""
|
||||
|
||||
# ─── Publishing settings ────────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
|
||||
@@ -28,6 +28,13 @@ steps = [
|
||||
|
||||
# ─── Configurable settings ───────────────────────────────────────────────────
|
||||
|
||||
[[settings]]
|
||||
key = "twitter_bearer_token"
|
||||
label = "Twitter Bearer Token"
|
||||
description = "Bearer Token from the Twitter/X Developer Portal. Required for all Twitter API operations."
|
||||
setting_type = "text"
|
||||
default = ""
|
||||
|
||||
[[settings]]
|
||||
key = "twitter_style"
|
||||
label = "Content Style"
|
||||
|
||||
Reference in New Issue
Block a user