bugfixes release

This commit is contained in:
jaberjaber23
2026-03-03 05:20:05 +03:00
parent 260dd7a125
commit 8942d8c2b6
15 changed files with 1046 additions and 76 deletions
Generated
+14 -14
View File
@@ -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
View File
@@ -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"
+32 -28
View File
@@ -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;
}
}
}
}
+98 -5
View File
@@ -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;
+47 -1
View File
@@ -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;
+43 -3
View File
@@ -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();
+18 -1
View File
@@ -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 {
+171 -8
View File
@@ -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) {
+169 -11
View File
@@ -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 } => {
+224 -3
View File
@@ -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"