Separate online and airplane AI profiles.

This commit is contained in:
2026-07-20 11:52:00 +02:00
parent 3739da0984
commit 2d6fcafc7e
21 changed files with 1147 additions and 538 deletions

View File

@@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- Local preview in the app or system browser.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing.
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
- SSH-agent-based SCP or rsync publishing.
- Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode.

View File

@@ -99,9 +99,9 @@ Open:
Available:
- Configurable online and airplane-mode OpenAI-compatible endpoints.
- Secure API-key storage through the operating-system keychain.
- Model catalog discovery and model selection.
- Independent online and airplane-mode OpenAI-compatible profiles, selected by the status-bar airplane switch.
- Secure keychain credentials for both profiles, optional for local endpoints.
- Model discovery without a preselected model; per-profile chat/title/image selections, explicit tool/vision overrides, and minimal chat tests.
- Post translation, media translation, image alt text, post analysis, taxonomy analysis, WordPress-import taxonomy mapping, and language detection.
- Explicit offline gating and user-visible errors.
- Parsed input, output, cache-read, and cache-write token usage returned from every one-shot operation; persistent chat accounting is tracked in the extension plan.

View File

@@ -40,7 +40,7 @@ Done:
- Project-aware tools over statistics, FTS search, posts, media, templates, scripts, tags/categories, metadata mutation through shared engines, and allowlisted workspace navigation.
- Localized Chat sidebar/editor with conversation and model controls, safe GFM text rendering with blocked external images, streaming/tool state, multiline send/stop controls, and status-bar token totals.
- Fixed native A2UI cards, charts, forms, lists, metrics, mind maps, tables, and tabs with safe nested fallbacks, persistent stable-ID interaction state, debounced form values, and strictly allowlisted application actions.
- Online/airplane endpoint routing uses the shared secure endpoint and model infrastructure; unavailable modes direct the user to the existing localized AI settings.
- Online/airplane routing swaps the complete active profile—endpoint, credentials, chat/title/image models, and tool/vision overrides—through the status-bar switch; unavailable modes direct the user to the localized two-profile AI settings.
### Embeddings, semantic search, and duplicates — Done

View File

@@ -883,6 +883,12 @@ fn config(db: &Database, command: ConfigCommand) -> Result<CommandOutput> {
cli_sync::run_cli_mutation(db.conn(), || {
if key == engine::settings::ONLINE_API_KEY {
engine::ai::save_online_api_key(db.conn(), &value)
} else if key == engine::settings::AIRPLANE_API_KEY {
engine::ai::save_endpoint_api_key(
db.conn(),
engine::ai::AiEndpointKind::Airplane,
&value,
)
} else {
engine::settings::set(db.conn(), &key, &value)
}

View File

@@ -53,12 +53,28 @@ pub struct StoredAiEndpointConfig {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AiSettings {
pub offline_mode: bool,
pub default_model: Option<String>,
pub system_prompt: String,
pub online: AiModeSettings,
pub airplane: AiModeSettings,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AiModeSettings {
pub endpoint: StoredAiEndpointConfig,
pub title_model: Option<String>,
pub image_model: Option<String>,
pub system_prompt: String,
pub online_endpoint: StoredAiEndpointConfig,
pub airplane_endpoint: StoredAiEndpointConfig,
pub chat_supports_tools: Option<bool>,
pub image_supports_vision: Option<bool>,
}
impl AiSettings {
pub fn active(&self) -> &AiModeSettings {
if self.offline_mode {
&self.airplane
} else {
&self.online
}
}
}
impl Default for StoredAiEndpointConfig {
@@ -78,6 +94,7 @@ pub struct AiModelInfo {
pub name: String,
pub context_window: Option<u64>,
pub max_output_tokens: Option<u64>,
pub supports_tools: bool,
pub supports_vision: bool,
}
@@ -163,21 +180,28 @@ pub enum OneShotResponse {
}
pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult<AiSettings> {
let online_endpoint = load_endpoint(conn, AiEndpointKind::Online)?;
let airplane_endpoint = load_endpoint(conn, AiEndpointKind::Airplane)?;
let default_model = get_optional_setting(conn, "ai.default_model")?;
let title_model = get_optional_setting(conn, "ai.title_model")?;
let image_model = get_optional_setting(conn, "ai.image_model")?;
let legacy_chat_model = get_optional_setting(conn, "ai.default_model")?;
let legacy_title_model = get_optional_setting(conn, "ai.title_model")?;
let legacy_image_model = get_optional_setting(conn, "ai.image_model")?;
let system_prompt = get_optional_setting(conn, "ai.system_prompt")?.unwrap_or_default();
Ok(AiSettings {
offline_mode,
default_model,
title_model,
image_model,
system_prompt,
online_endpoint,
airplane_endpoint,
online: load_mode_settings(
conn,
AiEndpointKind::Online,
legacy_chat_model.as_deref(),
legacy_title_model.as_deref(),
legacy_image_model.as_deref(),
)?,
airplane: load_mode_settings(
conn,
AiEndpointKind::Airplane,
legacy_chat_model.as_deref(),
legacy_title_model.as_deref(),
legacy_image_model.as_deref(),
)?,
})
}
@@ -196,20 +220,32 @@ pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineRe
endpoint.model.trim(),
checked_at,
)?;
if endpoint.kind == AiEndpointKind::Online {
set_setting(conn, "ai.default_model", "", checked_at)?;
if let Some(api_key) = &endpoint.api_key {
save_online_api_key_at(conn, api_key, checked_at)?;
}
save_endpoint_api_key_at(conn, endpoint.kind, api_key, checked_at)?;
}
Ok(())
}
pub fn save_online_api_key(conn: &Connection, api_key: &str) -> EngineResult<()> {
save_online_api_key_at(conn, api_key, now_unix_ms())
save_endpoint_api_key(conn, AiEndpointKind::Online, api_key)
}
fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> EngineResult<()> {
let entry = endpoint_keyring_entry(AiEndpointKind::Online)?;
pub fn save_endpoint_api_key(
conn: &Connection,
kind: AiEndpointKind,
api_key: &str,
) -> EngineResult<()> {
save_endpoint_api_key_at(conn, kind, api_key, now_unix_ms())
}
fn save_endpoint_api_key_at(
conn: &Connection,
kind: AiEndpointKind,
api_key: &str,
updated_at: i64,
) -> EngineResult<()> {
let entry = endpoint_keyring_entry(kind)?;
let configured = !api_key.trim().is_empty();
if configured {
entry.set_password(api_key.trim()).map_err(keyring_error)?;
@@ -221,7 +257,7 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) ->
}
set_setting(
conn,
&endpoint_setting_key(AiEndpointKind::Online, "api_key_configured"),
&endpoint_setting_key(kind, "api_key_configured"),
if configured { "true" } else { "false" },
updated_at,
)
@@ -229,19 +265,46 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) ->
pub fn save_model_preferences(
conn: &Connection,
default_model: Option<&str>,
kind: AiEndpointKind,
title_model: Option<&str>,
image_model: Option<&str>,
system_prompt: &str,
chat_supports_tools: Option<bool>,
image_supports_vision: Option<bool>,
) -> EngineResult<()> {
let updated_at = now_unix_ms();
set_optional_setting(conn, "ai.default_model", default_model, updated_at)?;
set_optional_setting(conn, "ai.title_model", title_model, updated_at)?;
set_optional_setting(conn, "ai.image_model", image_model, updated_at)?;
set_setting(conn, "ai.system_prompt", system_prompt, updated_at)?;
set_optional_setting(
conn,
&endpoint_setting_key(kind, "title_model"),
title_model,
updated_at,
)?;
set_optional_setting(
conn,
&endpoint_setting_key(kind, "image_model"),
image_model,
updated_at,
)?;
set_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "chat_supports_tools"),
chat_supports_tools,
updated_at,
)?;
set_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "image_supports_vision"),
image_supports_vision,
updated_at,
)?;
set_setting(conn, "ai.title_model", "", updated_at)?;
set_setting(conn, "ai.image_model", "", updated_at)?;
Ok(())
}
pub fn save_system_prompt(conn: &Connection, system_prompt: &str) -> EngineResult<()> {
set_setting(conn, "ai.system_prompt", system_prompt, now_unix_ms())
}
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
let kind = if offline_mode {
AiEndpointKind::Airplane
@@ -255,14 +318,20 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<Ai
kind.as_str()
)));
}
let api_key = if kind == AiEndpointKind::Online {
let entry = endpoint_keyring_entry(kind)?;
let password = entry.get_password().map_err(keyring_error)?;
if password.trim().is_empty() {
if kind == AiEndpointKind::Online && !stored.api_key_configured {
return Err(EngineError::Validation(
"AI unavailable - configure online endpoint in Settings".to_string(),
));
}
let api_key = if stored.api_key_configured {
let entry = endpoint_keyring_entry(kind)?;
let password = entry.get_password().map_err(keyring_error)?;
if password.trim().is_empty() {
return Err(EngineError::Validation(format!(
"AI unavailable - configure {} endpoint in Settings",
kind.as_str()
)));
}
Some(password)
} else {
None
@@ -287,7 +356,7 @@ pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String
}
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
validate_endpoint_config(endpoint)?;
validate_endpoint_access(endpoint)?;
let client = build_http_client()?;
let request = client.get(models_url(&endpoint.url));
let response = with_auth(request, endpoint).send()?.error_for_status()?;
@@ -325,11 +394,17 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
.any(|value| value.as_str() == Some("vision"))
})
.unwrap_or(false);
let supports_tools = model
.get("supports_tools")
.or_else(|| model.get("supportsTools"))
.and_then(Value::as_bool)
.unwrap_or(false);
result.push(AiModelInfo {
id,
name,
context_window,
max_output_tokens,
supports_tools,
supports_vision,
});
}
@@ -337,8 +412,25 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
Ok(result)
}
pub fn test_endpoint(endpoint: &AiEndpointConfig) -> EngineResult<()> {
let _ = refresh_model_catalog(endpoint)?;
pub fn test_chat(endpoint: &AiEndpointConfig, model: &str) -> EngineResult<()> {
validate_endpoint_access(endpoint)?;
if model.trim().is_empty() {
return Err(EngineError::Validation("model is required".to_string()));
}
let payload = json!({
"model": model.trim(),
"messages": [{"role": "user", "content": "Reply with OK."}],
"max_tokens": 8,
"stream": false,
});
with_auth(
build_http_client()?
.post(chat_completions_url(&endpoint.url))
.json(&payload),
endpoint,
)
.send()?
.error_for_status()?;
Ok(())
}
@@ -433,17 +525,50 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<Stored
})
}
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
if endpoint.url.trim().is_empty() {
return Err(EngineError::Validation(
"endpoint url is required".to_string(),
));
fn load_mode_settings(
conn: &Connection,
kind: AiEndpointKind,
legacy_chat_model: Option<&str>,
legacy_title_model: Option<&str>,
legacy_image_model: Option<&str>,
) -> EngineResult<AiModeSettings> {
let mut endpoint = load_endpoint(conn, kind)?;
if endpoint.model.trim().is_empty() {
endpoint.model = legacy_chat_model.unwrap_or_default().to_string();
}
Ok(AiModeSettings {
endpoint,
title_model: get_optional_setting(conn, &endpoint_setting_key(kind, "title_model"))?
.or_else(|| legacy_title_model.map(str::to_string)),
image_model: get_optional_setting(conn, &endpoint_setting_key(kind, "image_model"))?
.or_else(|| legacy_image_model.map(str::to_string)),
chat_supports_tools: get_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "chat_supports_tools"),
)?,
image_supports_vision: get_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "image_supports_vision"),
)?,
})
}
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
validate_endpoint_access(endpoint)?;
if endpoint.model.trim().is_empty() {
return Err(EngineError::Validation(
"endpoint model is required".to_string(),
));
}
Ok(())
}
fn validate_endpoint_access(endpoint: &AiEndpointConfig) -> EngineResult<()> {
if endpoint.url.trim().is_empty() {
return Err(EngineError::Validation(
"endpoint url is required".to_string(),
));
}
if endpoint.kind == AiEndpointKind::Online
&& endpoint
.api_key
@@ -463,17 +588,23 @@ fn select_model(
endpoint: &AiEndpointConfig,
operation: &OneShotOperation,
) -> EngineResult<String> {
let active = settings.active();
let selected = match operation {
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
OneShotOperation::AnalyzeImage => {
if active.image_supports_vision == Some(false) {
return Err(EngineError::Validation(
"AI unavailable - selected image model is not configured for vision"
.to_string(),
));
}
active.image_model.as_ref()
}
OneShotOperation::AnalyzeTaxonomy
| OneShotOperation::MapImportTaxonomy
| OneShotOperation::AnalyzePost
| OneShotOperation::DetectLanguage
| OneShotOperation::TranslatePost { .. }
| OneShotOperation::TranslateMedia { .. } => settings
.title_model
.as_ref()
.or(settings.default_model.as_ref()),
| OneShotOperation::TranslateMedia { .. } => active.title_model.as_ref(),
}
.filter(|model| !model.trim().is_empty())
.cloned()
@@ -746,6 +877,22 @@ fn set_optional_setting(
set_setting(conn, key, value.unwrap_or(""), updated_at)
}
fn set_optional_bool_setting(
conn: &Connection,
key: &str,
value: Option<bool>,
updated_at: i64,
) -> EngineResult<()> {
set_setting(
conn,
key,
value
.map(|value| if value { "true" } else { "false" })
.unwrap_or(""),
updated_at,
)
}
fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
match setting::get_setting_by_key(conn, key) {
Ok(setting) if setting.value.trim().is_empty() => Ok(None),
@@ -755,6 +902,10 @@ fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<Str
}
}
fn get_optional_bool_setting(conn: &Connection, key: &str) -> EngineResult<Option<bool>> {
Ok(get_optional_setting(conn, key)?.map(|value| value == "true"))
}
fn models_url(base_url: &str) -> String {
join_openai_path(base_url, "models")
}
@@ -808,9 +959,9 @@ mod tests {
let db = setup();
let settings = load_ai_settings(db.conn(), false).unwrap();
assert!(!settings.offline_mode);
assert!(settings.online_endpoint.url.is_empty());
assert!(settings.airplane_endpoint.url.is_empty());
assert!(settings.default_model.is_none());
assert!(settings.online.endpoint.url.is_empty());
assert!(settings.airplane.endpoint.url.is_empty());
assert!(settings.online.title_model.is_none());
}
#[test]
@@ -859,24 +1010,125 @@ mod tests {
let server = spawn_test_server(
|request| {
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
assert!(
request.contains("authorization: Bearer dummy")
|| request.contains("Authorization: Bearer dummy")
);
http_ok(
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"supports_tools":true,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
)
},
1,
);
let models = refresh_model_catalog(&AiEndpointConfig {
kind: AiEndpointKind::Airplane,
kind: AiEndpointKind::Online,
url: server,
model: "gpt-4.1-mini".to_string(),
api_key: None,
model: String::new(),
api_key: Some("dummy".to_string()),
})
.unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0].name, "GPT 4.1 mini");
assert!(models[0].supports_tools);
assert!(models[1].supports_vision);
}
#[test]
fn model_preferences_are_independent_per_endpoint() {
let db = setup();
save_model_preferences(
db.conn(),
AiEndpointKind::Online,
Some("online-title"),
Some("online-image"),
Some(true),
Some(true),
)
.unwrap();
save_model_preferences(
db.conn(),
AiEndpointKind::Airplane,
Some("local-title"),
Some("local-image"),
Some(false),
Some(true),
)
.unwrap();
let settings = load_ai_settings(db.conn(), false).unwrap();
assert_eq!(settings.online.title_model.as_deref(), Some("online-title"));
assert_eq!(
settings.airplane.title_model.as_deref(),
Some("local-title")
);
assert_eq!(settings.online.chat_supports_tools, Some(true));
assert_eq!(settings.airplane.chat_supports_tools, Some(false));
assert_eq!(settings.online.image_supports_vision, Some(true));
assert_eq!(settings.airplane.image_supports_vision, Some(true));
}
#[test]
fn test_chat_sends_the_selected_model() {
let server = spawn_test_server(
|request| {
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
assert!(request.contains(r#""model":"qwen-9b""#));
assert!(
request.contains("authorization: Bearer dummy")
|| request.contains("Authorization: Bearer dummy")
);
http_ok(r#"{"choices":[{"message":{"content":"OK"}}]}"#)
},
1,
);
test_chat(
&AiEndpointConfig {
kind: AiEndpointKind::Airplane,
url: server,
model: String::new(),
api_key: Some("dummy".to_string()),
},
"qwen-9b",
)
.unwrap();
}
#[test]
fn explicit_vision_override_blocks_image_requests() {
let db = setup();
save_endpoint(
db.conn(),
&AiEndpointConfig {
kind: AiEndpointKind::Airplane,
url: "http://localhost:11434/v1".to_string(),
model: "qwen".to_string(),
api_key: None,
},
)
.unwrap();
save_model_preferences(
db.conn(),
AiEndpointKind::Airplane,
None,
Some("qwen-vl"),
Some(false),
Some(false),
)
.unwrap();
let error = run_one_shot(
db.conn(),
true,
&OneShotRequest {
operation: OneShotOperation::AnalyzeImage,
content: json!({"image_data_url": "data:image/jpeg;base64,abc123"}),
},
)
.unwrap_err();
assert!(error.to_string().contains("not configured for vision"));
}
#[test]
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
fn run_one_shot_uses_active_endpoint_and_parses_response() {
@@ -909,7 +1161,15 @@ mod tests {
},
)
.unwrap();
save_model_preferences(db.conn(), None, Some("gpt-4.1-mini"), None, "").unwrap();
save_model_preferences(
db.conn(),
AiEndpointKind::Online,
Some("gpt-4.1-mini"),
None,
None,
None,
)
.unwrap();
let (response, usage) = run_one_shot(
db.conn(),

View File

@@ -84,6 +84,7 @@ pub struct ChatSendOptions {
pub max_output_tokens: u64,
pub max_tool_rounds: usize,
pub enable_tools: bool,
pub model_supports_tools: Option<bool>,
pub event_handler: Option<Arc<dyn Fn(ChatEvent) + Send + Sync>>,
}
@@ -96,6 +97,7 @@ impl Default for ChatSendOptions {
max_output_tokens: DEFAULT_OUTPUT_TOKENS,
max_tool_rounds: MAX_TOOL_ROUNDS,
enable_tools: true,
model_supports_tools: None,
event_handler: None,
}
}
@@ -467,7 +469,7 @@ pub fn send_chat_message(
offline_mode: bool,
conversation_id: &str,
content: &str,
options: ChatSendOptions,
mut options: ChatSendOptions,
) -> EngineResult<ChatTurnResult> {
let content = content.trim();
if content.is_empty() {
@@ -480,6 +482,11 @@ pub fn send_chat_message(
Some(endpoint) => validate_runtime_endpoint(endpoint)?,
None => ai::active_endpoint(conn, offline_mode)?,
};
if options.model_supports_tools.is_none() {
options.model_supports_tools = ai::load_ai_settings(conn, offline_mode)?
.active()
.chat_supports_tools;
}
let model = options
.model
.clone()
@@ -568,7 +575,11 @@ fn run_turns(
) -> EngineResult<ChatTurnResult> {
let mut total_usage = TokenUsage::default();
let max_rounds = options.max_tool_rounds.min(MAX_TOOL_ROUNDS);
let supports_tools = options.enable_tools && chat_tools::model_supports_tools(conn, model)?;
let supports_tools = options.enable_tools
&& match options.model_supports_tools {
Some(supports_tools) => supports_tools,
None => chat_tools::model_supports_tools(conn, model)?,
};
let catalog_model = list_models(conn)?
.into_iter()
.find(|candidate| candidate.id == model);
@@ -841,7 +852,28 @@ fn request_completion(
if let Some(api_key) = endpoint.api_key.as_deref() {
request = request.bearer_auth(api_key);
}
let mut response = request.send()?.error_for_status()?;
let mut response = request.send()?;
let status = response.status();
if !status.is_success() {
let body = response.text().unwrap_or_default();
let detail = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|value| {
value
.pointer("/error/message")
.or_else(|| value.get("error"))
.or_else(|| value.get("message"))
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| body.trim().to_string());
let detail = (!detail.is_empty())
.then_some(format!(": {detail}"))
.unwrap_or_default();
return Err(EngineError::Parse(format!(
"AI provider returned {status}{detail}"
)));
}
let is_stream = response
.headers()
.get(reqwest::header::CONTENT_TYPE)

View File

@@ -7,7 +7,9 @@ use crate::util::now_unix_ms;
pub const UI_LANGUAGE_KEY: &str = "ui.language";
pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key";
pub const AIRPLANE_API_KEY: &str = "ai.endpoint.airplane.api_key";
const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured";
const AIRPLANE_API_KEY_CONFIGURED: &str = "ai.endpoint.airplane.api_key_configured";
const DEFAULTS: &[(&str, &str)] = &[
("editor.default_mode", "markdown"),
("editor.diff_view_style", "inline"),
@@ -15,9 +17,18 @@ const DEFAULTS: &[(&str, &str)] = &[
("editor.hide_unchanged_regions", "false"),
("ai.endpoint.online.url", ""),
("ai.endpoint.online.model", ""),
("ai.endpoint.online.title_model", ""),
("ai.endpoint.online.image_model", ""),
("ai.endpoint.online.chat_supports_tools", ""),
("ai.endpoint.online.image_supports_vision", ""),
(ONLINE_API_KEY, ""),
("ai.endpoint.airplane.url", ""),
("ai.endpoint.airplane.model", ""),
("ai.endpoint.airplane.title_model", ""),
("ai.endpoint.airplane.image_model", ""),
("ai.endpoint.airplane.chat_supports_tools", ""),
("ai.endpoint.airplane.image_supports_vision", ""),
(AIRPLANE_API_KEY, ""),
("ai.default_model", ""),
("ai.title_model", ""),
("ai.image_model", ""),
@@ -37,9 +48,14 @@ pub fn get(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
}
pub fn get_effective(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
if key == ONLINE_API_KEY {
if key == ONLINE_API_KEY || key == AIRPLANE_API_KEY {
let configured_key = if key == ONLINE_API_KEY {
ONLINE_API_KEY_CONFIGURED
} else {
AIRPLANE_API_KEY_CONFIGURED
};
return Ok(Some(
get(conn, ONLINE_API_KEY_CONFIGURED)?
get(conn, configured_key)?
.filter(|value| value == "true")
.map_or_else(String::new, |_| "configured".to_string()),
));
@@ -69,6 +85,8 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
if !value.key.starts_with("app.")
&& value.key != ONLINE_API_KEY
&& value.key != ONLINE_API_KEY_CONFIGURED
&& value.key != AIRPLANE_API_KEY
&& value.key != AIRPLANE_API_KEY_CONFIGURED
{
values.insert(value.key, value.value);
}
@@ -76,6 +94,9 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
if get(conn, ONLINE_API_KEY_CONFIGURED)?.as_deref() == Some("true") {
values.insert(ONLINE_API_KEY.to_string(), "configured".to_string());
}
if get(conn, AIRPLANE_API_KEY_CONFIGURED)?.as_deref() == Some("true") {
values.insert(AIRPLANE_API_KEY.to_string(), "configured".to_string());
}
Ok(values)
}

View File

@@ -233,6 +233,33 @@ fn malformed_stream_and_provider_error_keep_reopenable_user_turn() {
}
}
#[test]
fn non_success_response_preserves_provider_error_detail() {
let (_root, db, project_id, data_dir) = setup();
let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap();
let (url, server) = serve(vec![MockResponse::status(
400,
"application/json",
"{\"error\":{\"message\":\"tokenizer.chat_template is not set\"}}",
)]);
let error = chat::send_chat_message(
db.conn(),
&data_dir,
&project_id,
false,
&conversation.id,
"Hello",
options(url, |_| {}),
)
.unwrap_err()
.to_string();
server.join().unwrap();
assert!(error.contains("400"));
assert!(error.contains("tokenizer.chat_template is not set"));
}
#[test]
fn cancellation_persists_only_received_content_and_never_runs_later_work() {
let (_root, db, project_id, data_dir) = setup();

View File

@@ -1871,63 +1871,100 @@ impl TuiApp {
),
],
SettingSection::Ai => vec![
(
"Airplane mode",
"ai.airplane_mode",
FieldKind::Bool,
self.airplane.to_string(),
),
(
"Online endpoint URL",
"ai.endpoint.online.url",
FieldKind::Text,
ai.online_endpoint.url,
ai.online.endpoint.url,
),
(
"Online endpoint model",
"Online chat model",
"ai.endpoint.online.model",
FieldKind::Text,
ai.online_endpoint.model,
ai.online.endpoint.model,
),
(
"Online API key",
"ai.endpoint.online.api_key",
FieldKind::ReadOnly,
if ai.online_endpoint.api_key_configured {
if ai.online.endpoint.api_key_configured {
"Configured in the system keychain".into()
} else {
"Configure in the desktop application".into()
},
),
(
"Online title model",
"ai.endpoint.online.title_model",
FieldKind::Text,
ai.online.title_model.unwrap_or_default(),
),
(
"Online image model",
"ai.endpoint.online.image_model",
FieldKind::Text,
ai.online.image_model.unwrap_or_default(),
),
(
"Online chat supports tools",
"ai.endpoint.online.chat_supports_tools",
FieldKind::Bool,
ai.online.chat_supports_tools.unwrap_or(false).to_string(),
),
(
"Online image supports vision",
"ai.endpoint.online.image_supports_vision",
FieldKind::Bool,
ai.online.image_supports_vision.unwrap_or(false).to_string(),
),
(
"Airplane endpoint URL",
"ai.endpoint.airplane.url",
FieldKind::Text,
ai.airplane_endpoint.url,
ai.airplane.endpoint.url,
),
(
"Airplane endpoint model",
"Airplane chat model",
"ai.endpoint.airplane.model",
FieldKind::Text,
ai.airplane_endpoint.model,
ai.airplane.endpoint.model,
),
(
"Default model",
"ai.default_model",
FieldKind::Text,
ai.default_model.unwrap_or_default(),
"Airplane API key",
"ai.endpoint.airplane.api_key",
FieldKind::ReadOnly,
if ai.airplane.endpoint.api_key_configured {
"Configured in the system keychain".into()
} else {
"Configure in the desktop application".into()
},
),
(
"Title model",
"ai.title_model",
"Airplane title model",
"ai.endpoint.airplane.title_model",
FieldKind::Text,
ai.title_model.unwrap_or_default(),
ai.airplane.title_model.unwrap_or_default(),
),
(
"Image model",
"ai.image_model",
"Airplane image model",
"ai.endpoint.airplane.image_model",
FieldKind::Text,
ai.image_model.unwrap_or_default(),
ai.airplane.image_model.unwrap_or_default(),
),
(
"Airplane chat supports tools",
"ai.endpoint.airplane.chat_supports_tools",
FieldKind::Bool,
ai.airplane.chat_supports_tools.unwrap_or(false).to_string(),
),
(
"Airplane image supports vision",
"ai.endpoint.airplane.image_supports_vision",
FieldKind::Bool,
ai.airplane
.image_supports_vision
.unwrap_or(false)
.to_string(),
),
(
"System prompt",
@@ -2056,14 +2093,8 @@ impl TuiApp {
|| key.starts_with("publishing.")
|| key.starts_with("ai.endpoint.")
|| key.starts_with("mcp.agent.")
|| matches!(
key,
"ai.airplane_mode"
| "ai.default_model"
| "ai.title_model"
| "ai.image_model"
| "ai.system_prompt"
) {
|| key == "ai.system_prompt"
{
fallback
} else {
engine::settings::get_effective(db.conn(), key)?.unwrap_or(fallback)
@@ -2304,25 +2335,27 @@ impl TuiApp {
kind,
url: url.into(),
model: model.into(),
api_key: if kind == AiEndpointKind::Online {
engine::ai::load_endpoint_api_key(kind)?
} else {
None
},
api_key: engine::ai::load_endpoint_api_key(kind)?,
},
)?;
}
}
let prefix = format!("ai.endpoint.{}", kind.as_str());
engine::ai::save_model_preferences(
db.conn(),
optional_str(self.setting_value("ai.default_model")),
optional_str(self.setting_value("ai.title_model")),
optional_str(self.setting_value("ai.image_model")),
self.setting_value("ai.system_prompt"),
kind,
optional_str(self.setting_value(&format!("{prefix}.title_model"))),
optional_str(self.setting_value(&format!("{prefix}.image_model"))),
Some(
self.setting_value(&format!("{prefix}.chat_supports_tools"))
.eq_ignore_ascii_case("true"),
),
Some(
self.setting_value(&format!("{prefix}.image_supports_vision"))
.eq_ignore_ascii_case("true"),
),
)?;
self.airplane = self
.setting_value("ai.airplane_mode")
.eq_ignore_ascii_case("true");
}
engine::ai::save_system_prompt(db.conn(), self.setting_value("ai.system_prompt"))?;
Ok(())
}
@@ -2496,15 +2529,20 @@ fn setting_field_label(locale: UiLocale, key: &str, fallback: &str) -> String {
"meta.blogmark_category" => "settings.blogmarkCategory",
"meta.categories" => "editor.categories",
"meta.category_editing" => "tui.categoryEditing",
"ai.airplane_mode" => "settings.offlineMode",
"ai.endpoint.online.url" => "settings.onlineEndpointUrl",
"ai.endpoint.online.model" => "settings.onlineEndpointModel",
"ai.endpoint.online.model" => "settings.onlineChatModel",
"ai.endpoint.online.api_key" => "settings.onlineApiKey",
"ai.endpoint.online.title_model" => "settings.onlineTitleModel",
"ai.endpoint.online.image_model" => "settings.onlineImageModel",
"ai.endpoint.online.chat_supports_tools" => "settings.onlineModelSupportsTools",
"ai.endpoint.online.image_supports_vision" => "settings.onlineModelSupportsVision",
"ai.endpoint.airplane.url" => "settings.airplaneEndpointUrl",
"ai.endpoint.airplane.model" => "settings.airplaneEndpointModel",
"ai.default_model" => "settings.defaultModel",
"ai.title_model" => "settings.titleModel",
"ai.image_model" => "settings.imageAnalysisModel",
"ai.endpoint.airplane.model" => "settings.airplaneChatModel",
"ai.endpoint.airplane.api_key" => "settings.airplaneApiKey",
"ai.endpoint.airplane.title_model" => "settings.airplaneTitleModel",
"ai.endpoint.airplane.image_model" => "settings.airplaneImageModel",
"ai.endpoint.airplane.chat_supports_tools" => "settings.airplaneModelSupportsTools",
"ai.endpoint.airplane.image_supports_vision" => "settings.airplaneModelSupportsVision",
"ai.system_prompt" => "settings.systemPrompt",
"technology.runtime" => "tui.settingRuntime",
"meta.semantic_similarity_enabled" => "settings.semanticSimilarityEnabled",
@@ -4083,16 +4121,9 @@ mod tests {
.find(|field| field.key == "ai.endpoint.airplane.model")
.unwrap()
.value = "local-model".into();
remote
.settings_fields
.iter_mut()
.find(|field| field.key == "ai.default_model")
.unwrap()
.value = "local-model".into();
remote.save_settings().unwrap();
let ai = engine::ai::load_ai_settings(fixture.db().conn(), false).unwrap();
assert_eq!(ai.airplane_endpoint.model, "local-model");
assert_eq!(ai.default_model.as_deref(), Some("local-model"));
assert_eq!(ai.airplane.endpoint.model, "local-model");
}
#[test]

View File

@@ -47,7 +47,8 @@ use crate::views::{
post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink},
script_editor::{ScriptEditorMsg, ScriptEditorState},
settings_view::{
AiModelOption, SettingsCategoryRow, SettingsMsg, SettingsViewState, default_category_rows,
AiModeViewState, AiModelOption, SettingsCategoryRow, SettingsMsg, SettingsViewState,
default_category_rows,
},
site_validation::SiteValidationState,
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
@@ -1501,13 +1502,14 @@ impl BdsApp {
conversation_id,
result,
} => {
let locale = self.ui_locale;
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
state.streaming = false;
state.clear_streaming();
state.active_tool = None;
match result {
Ok(_) => state.error = None,
Err(error) => state.error = Some(error),
Err(error) => state.error = Some(localize_chat_error(locale, &error)),
}
if let Some(db) = &self.db {
state.set_messages(
@@ -2572,6 +2574,22 @@ impl BdsApp {
// ── Settings ──
Message::SetOfflineMode(mode) => {
self.offline_mode = mode;
let models = self.chat_model_options();
let selected = self.db.as_ref().and_then(|db| {
ai::load_ai_settings(db.conn(), mode)
.ok()
.map(|settings| settings.active().endpoint.model.clone())
.filter(|model| !model.trim().is_empty())
});
for (id, state) in &mut self.chat_editors {
state.model_options = models.clone();
if let Some(model) = selected.as_ref() {
state.conversation.model = Some(model.clone());
if let Some(db) = &self.db {
let _ = engine::chat::set_conversation_model(db.conn(), id, model);
}
}
}
self.sync_menu_state();
Task::none()
}
@@ -3546,62 +3564,33 @@ impl BdsApp {
fn chat_model_options(&self) -> Vec<ChatModelChoice> {
let mut models = self
.db
.settings_state
.as_ref()
.and_then(|db| engine::chat::list_models(db.conn()).ok())
.into_iter()
.flatten()
.map(|model| {
let context = model.context_window.div_ceil(1_000).to_string();
let output = model.max_output_tokens.div_ceil(1_000).to_string();
let capability = t(
self.ui_locale,
if model.supports_tools {
"chat.model.tools"
.map(|state| {
let mode = if self.offline_mode {
&state.airplane_ai
} else {
"chat.model.textOnly"
},
);
ChatModelChoice {
id: model.id,
label: tw(
self.ui_locale,
"chat.model.option",
&[
("provider", &model.provider),
("name", &model.name),
("context", &context),
("output", &output),
("capability", &capability),
],
),
}
&state.online_ai
};
mode.model_options
.iter()
.map(|model| ChatModelChoice {
id: model.id.clone(),
label: model.label.clone(),
})
.collect::<Vec<_>>();
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Some(db) = &self.db
&& let Ok(settings) = ai::load_ai_settings(db.conn(), self.offline_mode)
{
if let Some(model) = settings.default_model
&& !models.iter().any(|choice| choice.id == model)
{
let model = settings.active().endpoint.model.clone();
if !model.trim().is_empty() && !models.iter().any(|choice| choice.id == model) {
models.push(ChatModelChoice {
id: model.clone(),
label: model,
});
}
let endpoint_model = if self.offline_mode {
settings.airplane_endpoint.model
} else {
settings.online_endpoint.model
};
if !endpoint_model.trim().is_empty()
&& !models.iter().any(|choice| choice.id == endpoint_model)
{
models.push(ChatModelChoice {
id: endpoint_model.clone(),
label: endpoint_model,
});
}
}
models.sort_by(|left, right| left.label.cmp(&right.label));
models.dedup_by(|left, right| left.id == right.id);
@@ -3610,10 +3599,17 @@ impl BdsApp {
fn create_chat_conversation(&mut self) -> Task<Message> {
let model = self
.chat_model_options()
.db
.as_ref()
.and_then(|db| ai::load_ai_settings(db.conn(), self.offline_mode).ok())
.map(|settings| settings.active().endpoint.model.clone())
.filter(|model| !model.trim().is_empty())
.or_else(|| {
self.chat_model_options()
.into_iter()
.next()
.map(|choice| choice.id);
.map(|choice| choice.id)
});
let title = model.as_deref().map_or_else(
|| t(self.ui_locale, "chat.new"),
|model| tw(self.ui_locale, "chat.newWithModel", &[("model", model)]),
@@ -3735,6 +3731,7 @@ impl BdsApp {
conversation_id,
message,
} => {
let message = localize_chat_error(self.ui_locale, &message);
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
state.error = Some(message);
}
@@ -3939,15 +3936,15 @@ impl BdsApp {
.iter()
.any(|tab| tab.tab_type == TabType::Settings)
{
self.settings_state = None;
if let Some(tab) = self
.tabs
.iter()
.find(|tab| tab.tab_type == TabType::Settings)
.cloned()
{
self.load_editor_for_tab(&tab);
let active_section = self
.settings_state
.as_ref()
.and_then(|state| state.active_section.clone());
let mut state = self.hydrate_settings_state();
if let Some(section) = active_section {
state.focus_section(section);
}
self.settings_state = Some(state);
}
false
}
@@ -6822,28 +6819,8 @@ impl BdsApp {
state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value);
}
if let Ok(ai_settings) = ai::load_ai_settings(db.conn(), self.offline_mode) {
state.online_endpoint_url = ai_settings.online_endpoint.url;
state.online_endpoint_model = ai_settings.online_endpoint.model;
state.online_api_key_configured = ai_settings.online_endpoint.api_key_configured;
if !state.online_endpoint_model.is_empty() {
state.online_model_options = vec![AiModelOption {
id: state.online_endpoint_model.clone(),
label: state.online_endpoint_model.clone(),
supports_vision: false,
}];
}
state.airplane_endpoint_url = ai_settings.airplane_endpoint.url;
state.airplane_endpoint_model = ai_settings.airplane_endpoint.model;
if !state.airplane_endpoint_model.is_empty() {
state.airplane_model_options = vec![AiModelOption {
id: state.airplane_endpoint_model.clone(),
label: state.airplane_endpoint_model.clone(),
supports_vision: false,
}];
}
state.default_model = ai_settings.default_model.unwrap_or_default();
state.title_model = ai_settings.title_model.unwrap_or_default();
state.image_model = ai_settings.image_model.unwrap_or_default();
state.online_ai = Self::ai_mode_view_state(ai_settings.online);
state.airplane_ai = Self::ai_mode_view_state(ai_settings.airplane);
}
state.mcp_enabled = engine::settings::get_effective(db.conn(), "mcp.http.enabled")
.ok()
@@ -6874,7 +6851,6 @@ impl BdsApp {
})
.collect();
}
state.offline_mode = self.offline_mode;
state
}
@@ -7507,51 +7483,58 @@ impl BdsApp {
state.ssh_username.clear();
state.ssh_remote_path.clear();
}
SettingsMsg::OfflineModeChanged(b) => {
state.offline_mode = b;
return Task::done(Message::SetOfflineMode(b));
SettingsMsg::AiEndpointUrlChanged(kind, value) => {
Self::ai_mode_state_mut(state, kind).endpoint_url = value;
}
SettingsMsg::OnlineEndpointUrlChanged(value) => {
state.online_endpoint_url = value;
SettingsMsg::AiApiKeyChanged(kind, value) => {
Self::ai_mode_state_mut(state, kind).api_key_input = value;
}
SettingsMsg::OnlineEndpointModelChanged(value) => {
state.online_endpoint_model = value;
SettingsMsg::AiChatModelChanged(kind, value) => {
let mode = Self::ai_mode_state_mut(state, kind);
mode.chat_supports_tools = mode
.model_options
.iter()
.find(|option| option.id == value)
.is_some_and(|option| option.supports_tools);
mode.chat_model = value;
}
SettingsMsg::OnlineApiKeyChanged(value) => {
state.online_api_key_input = value;
SettingsMsg::AiTitleModelChanged(kind, value) => {
Self::ai_mode_state_mut(state, kind).title_model = value;
}
SettingsMsg::AirplaneEndpointUrlChanged(value) => {
state.airplane_endpoint_url = value;
SettingsMsg::AiImageModelChanged(kind, value) => {
let mode = Self::ai_mode_state_mut(state, kind);
mode.image_supports_vision = mode
.model_options
.iter()
.find(|option| option.id == value)
.is_some_and(|option| option.supports_vision);
mode.image_model = value;
}
SettingsMsg::AirplaneEndpointModelChanged(value) => {
state.airplane_endpoint_model = value;
SettingsMsg::AiToolsChanged(kind, value) => {
Self::ai_mode_state_mut(state, kind).chat_supports_tools = value;
}
SettingsMsg::DefaultModelChanged(value) => {
state.default_model = value;
SettingsMsg::AiVisionChanged(kind, value) => {
Self::ai_mode_state_mut(state, kind).image_supports_vision = value;
}
SettingsMsg::TitleModelChanged(value) => {
state.title_model = value;
}
SettingsMsg::ImageModelChanged(value) => {
state.image_model = value;
}
SettingsMsg::RefreshOnlineModels => {
SettingsMsg::RefreshAiModels(kind) => {
if let Some(db) = &self.db {
match Self::refresh_ai_models(db, state, AiEndpointKind::Online) {
Ok(()) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
}
Err(error) => self.notify_operation_failed("common.save", error),
match Self::refresh_ai_models(db, state, kind) {
Ok(()) => self.notify(
ToastLevel::Success,
&t(self.ui_locale, "settings.modelsLoaded"),
),
Err(error) => self.notify_operation_failed("settings.refreshModels", error),
}
}
}
SettingsMsg::RefreshAirplaneModels => {
SettingsMsg::TestAi(kind) => {
if let Some(db) = &self.db {
match Self::refresh_ai_models(db, state, AiEndpointKind::Airplane) {
Ok(()) => {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"))
}
Err(error) => self.notify_operation_failed("common.save", error),
match Self::test_ai_settings(db, state, kind) {
Ok(()) => self.notify(
ToastLevel::Success,
&t(self.ui_locale, "settings.testChatSuccess"),
),
Err(error) => self.notify_operation_failed("settings.testChat", error),
}
}
}
@@ -8160,37 +8143,14 @@ impl BdsApp {
}
}
TabType::Settings if self.settings_state.is_none() => {
let mut state = SettingsViewState::default();
if let Some(ref project) = self.active_project {
state.project_name = project.name.clone();
state.project_description = iced::widget::text_editor::Content::with_text(
&project.description.clone().unwrap_or_default(),
);
state.data_path = project.data_path.clone().unwrap_or_default();
}
if let Some(ref data_dir) = self.data_dir {
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
state.public_url = meta.public_url.unwrap_or_default();
state.default_author = meta.default_author.unwrap_or_default();
state.max_posts_per_page = meta.max_posts_per_page.to_string();
state.image_import_concurrency = meta.image_import_concurrency.to_string();
}
if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) {
state.ssh_host = pub_prefs.ssh_host.unwrap_or_default();
state.ssh_username = pub_prefs.ssh_user.unwrap_or_default();
state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default();
state.ssh_mode = format!("{:?}", pub_prefs.ssh_mode).to_lowercase();
}
}
state.offline_mode = self.offline_mode;
self.settings_state = Some(state);
self.settings_state = Some(self.hydrate_settings_state());
}
_ => {}
}
}
fn refresh_ai_models(
db: &Database,
_db: &Database,
state: &mut SettingsViewState,
kind: AiEndpointKind,
) -> Result<(), String> {
@@ -8201,92 +8161,122 @@ impl BdsApp {
.map(|model| AiModelOption {
id: model.id,
label: model.name,
supports_tools: model.supports_tools,
supports_vision: model.supports_vision,
})
.collect::<Vec<_>>();
match kind {
AiEndpointKind::Online => state.online_model_options = options,
AiEndpointKind::Airplane => state.airplane_model_options = options,
Self::ai_mode_state_mut(state, kind).model_options = options;
Ok(())
}
fn test_ai_settings(
_db: &Database,
state: &SettingsViewState,
kind: AiEndpointKind,
) -> Result<(), String> {
let endpoint = Self::compose_ai_endpoint(state, kind)?;
let mode = Self::ai_mode_state(state, kind);
let models = [&mode.chat_model, &mode.title_model, &mode.image_model]
.into_iter()
.filter(|model| !model.trim().is_empty())
.collect::<std::collections::BTreeSet<_>>();
if models.is_empty() {
return Err("select at least one model".to_string());
}
for model in models {
ai::test_chat(&endpoint, model).map_err(|error| error.to_string())?;
}
let _ = db;
Ok(())
}
fn save_ai_settings_state(db: &Database, state: &mut SettingsViewState) -> Result<(), String> {
if Self::endpoint_has_configuration(state, AiEndpointKind::Online) {
let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?;
ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?;
ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?;
state.online_api_key_input.clear();
state.online_api_key_configured = true;
}
if Self::endpoint_has_configuration(state, AiEndpointKind::Airplane) {
let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?;
ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?;
ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?;
for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] {
if Self::endpoint_has_configuration(state, kind) {
let endpoint = Self::compose_ai_endpoint(state, kind)?;
ai::save_endpoint(db.conn(), &endpoint).map_err(|error| error.to_string())?;
}
let mode = Self::ai_mode_state(state, kind);
ai::save_model_preferences(
db.conn(),
(!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()),
(!state.title_model.trim().is_empty()).then_some(state.title_model.as_str()),
(!state.image_model.trim().is_empty()).then_some(state.image_model.as_str()),
&state.system_prompt.text(),
kind,
(!mode.title_model.trim().is_empty()).then_some(mode.title_model.as_str()),
(!mode.image_model.trim().is_empty()).then_some(mode.image_model.as_str()),
Some(mode.chat_supports_tools),
Some(mode.image_supports_vision),
)
.map_err(|error| error.to_string())?;
}
ai::save_system_prompt(db.conn(), &state.system_prompt.text())
.map_err(|error| error.to_string())?;
for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] {
let mode = Self::ai_mode_state_mut(state, kind);
if !mode.api_key_input.trim().is_empty() {
mode.api_key_configured = true;
}
mode.api_key_input.clear();
}
Ok(())
}
fn endpoint_has_configuration(state: &SettingsViewState, kind: AiEndpointKind) -> bool {
match kind {
AiEndpointKind::Online => {
!state.online_endpoint_url.trim().is_empty()
|| !state.online_endpoint_model.trim().is_empty()
|| !state.online_api_key_input.trim().is_empty()
|| state.online_api_key_configured
}
AiEndpointKind::Airplane => {
!state.airplane_endpoint_url.trim().is_empty()
|| !state.airplane_endpoint_model.trim().is_empty()
}
}
let mode = Self::ai_mode_state(state, kind);
!mode.endpoint_url.trim().is_empty()
|| !mode.chat_model.trim().is_empty()
|| !mode.api_key_input.trim().is_empty()
|| mode.api_key_configured
}
fn compose_ai_endpoint(
state: &SettingsViewState,
kind: AiEndpointKind,
) -> Result<AiEndpointConfig, String> {
let (url, model, configured) = match kind {
AiEndpointKind::Online => (
state.online_endpoint_url.trim().to_string(),
state.online_endpoint_model.trim().to_string(),
state.online_api_key_configured,
),
AiEndpointKind::Airplane => (
state.airplane_endpoint_url.trim().to_string(),
state.airplane_endpoint_model.trim().to_string(),
false,
),
};
let api_key = if kind == AiEndpointKind::Online {
let input = state.online_api_key_input.trim();
if !input.is_empty() {
let mode = Self::ai_mode_state(state, kind);
let input = mode.api_key_input.trim();
let api_key = if !input.is_empty() {
Some(input.to_string())
} else if configured {
} else if mode.api_key_configured {
ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())?
} else {
None
}
} else {
None
};
Ok(AiEndpointConfig {
kind,
url,
model,
url: mode.endpoint_url.trim().to_string(),
model: mode.chat_model.trim().to_string(),
api_key,
})
}
fn ai_mode_state(state: &SettingsViewState, kind: AiEndpointKind) -> &AiModeViewState {
match kind {
AiEndpointKind::Online => &state.online_ai,
AiEndpointKind::Airplane => &state.airplane_ai,
}
}
fn ai_mode_state_mut(
state: &mut SettingsViewState,
kind: AiEndpointKind,
) -> &mut AiModeViewState {
match kind {
AiEndpointKind::Online => &mut state.online_ai,
AiEndpointKind::Airplane => &mut state.airplane_ai,
}
}
fn ai_mode_view_state(settings: ai::AiModeSettings) -> AiModeViewState {
AiModeViewState {
endpoint_url: settings.endpoint.url,
chat_model: settings.endpoint.model,
title_model: settings.title_model.unwrap_or_default(),
image_model: settings.image_model.unwrap_or_default(),
api_key_configured: settings.endpoint.api_key_configured,
chat_supports_tools: settings.chat_supports_tools.unwrap_or(false),
image_supports_vision: settings.image_supports_vision.unwrap_or(false),
..Default::default()
}
}
fn ensure_preview_server(&mut self) -> Result<(), String> {
let Some(project) = self.active_project.as_ref() else {
return Err(t(self.ui_locale, "engine.generateSiteNoProject"));
@@ -8995,6 +8985,16 @@ fn language_label(locale: UiLocale, code: &str) -> String {
}
}
fn localize_chat_error(locale: UiLocale, error: &str) -> String {
if error.contains("AI unavailable - configure") {
t(locale, "chat.unavailable.guidance")
} else if let Some(detail) = error.strip_prefix("parse error: AI provider returned ") {
format!("{} {detail}", t(locale, "chat.providerError"))
} else {
error.to_string()
}
}
fn remote_error_closes_connection(code: &str) -> bool {
code == "connection_lost"
}
@@ -9003,10 +9003,11 @@ fn remote_error_closes_connection(code: &str) -> bool {
mod tests {
use super::{
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
PostStatus, SettingsMsg, month_abbreviation, persist_media_editor_state_impl,
persist_post_editor_preview_state_impl, persist_post_editor_state_impl,
remote_error_closes_connection, save_editor_settings_state_impl,
save_script_editor_state_impl, save_template_editor_state_impl,
PostStatus, SettingsMsg, localize_chat_error, month_abbreviation,
persist_media_editor_state_impl, persist_post_editor_preview_state_impl,
persist_post_editor_state_impl, remote_error_closes_connection,
save_editor_settings_state_impl, save_script_editor_state_impl,
save_template_editor_state_impl,
};
use crate::i18n::t;
use crate::platform::menu::MenuAction;
@@ -9020,7 +9021,7 @@ mod tests {
use crate::views::modal;
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState};
use crate::views::settings_view::SettingsViewState;
use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState};
use crate::views::template_editor::TemplateEditorState;
use bds_core::db::Database;
use bds_core::db::fts::ensure_fts_tables;
@@ -9029,7 +9030,7 @@ mod tests {
use bds_core::engine::task::{TaskStatus, TaskStatus::*};
use bds_core::engine::{ai, chat, media, menu, meta, post, script, template, wordpress_import};
use bds_core::i18n::UiLocale;
use bds_core::model::{ChatRole, Project, ScriptKind, TemplateKind};
use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind};
use chrono::{Datelike, TimeZone};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -9922,8 +9923,11 @@ mod tests {
fn save_ai_settings_allows_airplane_only_configuration() {
let (db, _project, _tmp) = setup();
let mut state = SettingsViewState {
airplane_endpoint_url: spawn_models_server(),
airplane_endpoint_model: "llama3.2".to_string(),
airplane_ai: AiModeViewState {
endpoint_url: spawn_models_server(),
chat_model: "llama3.2".to_string(),
..Default::default()
},
system_prompt: iced::widget::text_editor::Content::with_text("Use JSON only."),
..SettingsViewState::default()
};
@@ -9931,13 +9935,86 @@ mod tests {
BdsApp::save_ai_settings_state(&db, &mut state).unwrap();
let settings = ai::load_ai_settings(db.conn(), false).unwrap();
assert!(settings.online_endpoint.url.is_empty());
assert!(settings.online_endpoint.model.is_empty());
assert_eq!(settings.airplane_endpoint.url, state.airplane_endpoint_url);
assert_eq!(settings.airplane_endpoint.model, "llama3.2");
assert!(settings.online.endpoint.url.is_empty());
assert!(settings.online.endpoint.model.is_empty());
assert_eq!(
settings.airplane.endpoint.url,
state.airplane_ai.endpoint_url
);
assert_eq!(settings.airplane.endpoint.model, "llama3.2");
assert_eq!(settings.system_prompt.trim_end(), "Use JSON only.");
}
#[test]
fn settings_change_rehydrates_ai_configuration() {
let (db, project, tmp) = setup();
bds_core::engine::settings::set(
db.conn(),
"ai.endpoint.online.url",
"http://127.0.0.1:9000/v1",
)
.unwrap();
bds_core::engine::settings::set(
db.conn(),
"ai.endpoint.online.model",
"mlx-community--gemma-4-12B-8bit",
)
.unwrap();
bds_core::engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true")
.unwrap();
let mut app = make_app(db, project, &tmp);
app.tabs.push(Tab {
id: "settings".to_string(),
tab_type: TabType::Settings,
title: "Settings".to_string(),
is_transient: false,
is_dirty: false,
});
let mut settings_state = SettingsViewState::default();
settings_state.focus_section(SettingsSection::AI);
app.settings_state = Some(settings_state);
app.handle_domain_event(DomainEvent::SettingsChanged {
project_id: None,
key: "ai.endpoint.online.url".to_string(),
});
let state = app.settings_state.as_ref().unwrap();
assert_eq!(state.online_ai.endpoint_url, "http://127.0.0.1:9000/v1");
assert_eq!(
state.online_ai.chat_model,
"mlx-community--gemma-4-12B-8bit"
);
assert!(state.online_ai.api_key_configured);
assert_eq!(state.active_section, Some(SettingsSection::AI));
}
#[test]
fn ai_unavailable_chat_errors_are_localized() {
let raw = "validation error: AI unavailable - configure online endpoint in Settings";
for locale in UiLocale::all() {
assert_eq!(
localize_chat_error(*locale, raw),
t(*locale, "chat.unavailable.guidance")
);
}
let provider =
"parse error: AI provider returned 400 Bad Request: tokenizer.chat_template is not set";
for locale in UiLocale::all() {
assert_eq!(
localize_chat_error(*locale, provider),
format!(
"{} 400 Bad Request: tokenizer.chat_template is not set",
t(*locale, "chat.providerError")
)
);
}
assert_eq!(
localize_chat_error(UiLocale::En, "connection refused"),
"connection refused"
);
}
#[test]
fn media_editor_save_flow_persists_changes() {
let (db, project, tmp) = setup();

View File

@@ -201,6 +201,31 @@ pub fn labeled_input<'a, Message: Clone + 'a>(
.into()
}
/// A labeled password input field.
pub fn labeled_secure_input<'a, Message: Clone + 'a>(
label: &str,
placeholder: &str,
value: &str,
on_change: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
column![
text(label.to_string())
.size(12)
.color(LABEL_COLOR)
.shaping(Shaping::Advanced),
text_input(placeholder, value)
.on_input(on_change)
.secure(true)
.size(14)
.padding([8, 10])
.width(Length::Fill)
.style(field_style),
]
.spacing(6)
.width(Length::Fill)
.into()
}
/// A labeled select/dropdown field.
pub fn labeled_select<'a, T, Message>(
label: &str,

View File

@@ -2,6 +2,7 @@ use iced::widget::text::Shaping;
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
use iced::{Alignment, Color, Element, Length};
use bds_core::engine::ai::AiEndpointKind;
use bds_core::i18n::UiLocale;
use crate::app::Message;
@@ -12,6 +13,7 @@ use crate::i18n::{t, tw};
pub struct AiModelOption {
pub id: String,
pub label: String,
pub supports_tools: bool,
pub supports_vision: bool,
}
@@ -21,6 +23,19 @@ impl std::fmt::Display for AiModelOption {
}
}
#[derive(Debug, Clone, Default)]
pub struct AiModeViewState {
pub endpoint_url: String,
pub chat_model: String,
pub title_model: String,
pub image_model: String,
pub api_key_input: String,
pub api_key_configured: bool,
pub model_options: Vec<AiModelOption>,
pub chat_supports_tools: bool,
pub image_supports_vision: bool,
}
/// Collapsible section identifiers per editor_settings.allium.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SettingsSection {
@@ -136,18 +151,8 @@ pub struct SettingsViewState {
pub ssh_username: String,
pub ssh_remote_path: String,
// AI
pub offline_mode: bool,
pub online_endpoint_url: String,
pub online_endpoint_model: String,
pub online_api_key_input: String,
pub online_api_key_configured: bool,
pub online_model_options: Vec<AiModelOption>,
pub airplane_endpoint_url: String,
pub airplane_endpoint_model: String,
pub airplane_model_options: Vec<AiModelOption>,
pub default_model: String,
pub title_model: String,
pub image_model: String,
pub online_ai: AiModeViewState,
pub airplane_ai: AiModeViewState,
pub system_prompt: text_editor::Content,
// Technology
pub semantic_similarity_enabled: bool,
@@ -195,18 +200,8 @@ impl Clone for SettingsViewState {
ssh_host: self.ssh_host.clone(),
ssh_username: self.ssh_username.clone(),
ssh_remote_path: self.ssh_remote_path.clone(),
offline_mode: self.offline_mode,
online_endpoint_url: self.online_endpoint_url.clone(),
online_endpoint_model: self.online_endpoint_model.clone(),
online_api_key_input: self.online_api_key_input.clone(),
online_api_key_configured: self.online_api_key_configured,
online_model_options: self.online_model_options.clone(),
airplane_endpoint_url: self.airplane_endpoint_url.clone(),
airplane_endpoint_model: self.airplane_endpoint_model.clone(),
airplane_model_options: self.airplane_model_options.clone(),
default_model: self.default_model.clone(),
title_model: self.title_model.clone(),
image_model: self.image_model.clone(),
online_ai: self.online_ai.clone(),
airplane_ai: self.airplane_ai.clone(),
system_prompt: text_editor::Content::with_text(&self.system_prompt.text()),
semantic_similarity_enabled: self.semantic_similarity_enabled,
mcp_enabled: self.mcp_enabled,
@@ -252,18 +247,8 @@ impl Default for SettingsViewState {
ssh_host: String::new(),
ssh_username: String::new(),
ssh_remote_path: String::new(),
offline_mode: false,
online_endpoint_url: String::new(),
online_endpoint_model: String::new(),
online_api_key_input: String::new(),
online_api_key_configured: false,
online_model_options: Vec::new(),
airplane_endpoint_url: String::new(),
airplane_endpoint_model: String::new(),
airplane_model_options: Vec::new(),
default_model: String::new(),
title_model: String::new(),
image_model: String::new(),
online_ai: AiModeViewState::default(),
airplane_ai: AiModeViewState::default(),
system_prompt: text_editor::Content::new(),
semantic_similarity_enabled: false,
mcp_enabled: false,
@@ -343,17 +328,15 @@ pub enum SettingsMsg {
SavePublishing,
ClearPublishing,
// AI
OfflineModeChanged(bool),
OnlineEndpointUrlChanged(String),
OnlineEndpointModelChanged(String),
OnlineApiKeyChanged(String),
RefreshOnlineModels,
AirplaneEndpointUrlChanged(String),
AirplaneEndpointModelChanged(String),
RefreshAirplaneModels,
DefaultModelChanged(String),
TitleModelChanged(String),
ImageModelChanged(String),
AiEndpointUrlChanged(AiEndpointKind, String),
AiApiKeyChanged(AiEndpointKind, String),
AiChatModelChanged(AiEndpointKind, String),
AiTitleModelChanged(AiEndpointKind, String),
AiImageModelChanged(AiEndpointKind, String),
AiToolsChanged(AiEndpointKind, bool),
AiVisionChanged(AiEndpointKind, bool),
RefreshAiModels(AiEndpointKind),
TestAi(AiEndpointKind),
SystemPromptAction(text_editor::Action),
SaveAi,
ResetSystemPrompt,
@@ -786,104 +769,17 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
}
fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let active_model_options = if state.offline_mode {
&state.airplane_model_options
} else {
&state.online_model_options
};
let model_options = std::iter::once(AiModelOption {
id: String::new(),
label: t(locale, "tags.noTemplate"),
supports_vision: false,
})
.chain(active_model_options.iter().cloned())
.collect::<Vec<_>>();
let image_model_options = std::iter::once(AiModelOption {
id: String::new(),
label: t(locale, "tags.noTemplate"),
supports_vision: false,
})
.chain(
active_model_options
.iter()
.filter(|option| option.supports_vision)
.cloned(),
)
.collect::<Vec<_>>();
let offline = inputs::labeled_checkbox(
&t(locale, "settings.offlineMode"),
state.offline_mode,
|b| Message::Settings(SettingsMsg::OfflineModeChanged(b)),
);
let online_url = inputs::labeled_input(
&t(locale, "settings.onlineEndpointUrl"),
let online = ai_mode_block(
&state.online_ai,
AiEndpointKind::Online,
locale,
"https://api.example.com/v1",
&state.online_endpoint_url,
|value| Message::Settings(SettingsMsg::OnlineEndpointUrlChanged(value)),
);
let online_model = inputs::labeled_input(
&t(locale, "settings.onlineEndpointModel"),
"gpt-4.1-mini",
&state.online_endpoint_model,
|value| Message::Settings(SettingsMsg::OnlineEndpointModelChanged(value)),
);
let keychain_placeholder = t(locale, "settings.keychainConfigured");
let online_api_key = inputs::labeled_input(
&t(locale, "settings.onlineApiKey"),
if state.online_api_key_configured {
&keychain_placeholder
} else {
"sk-..."
},
&state.online_api_key_input,
|value| Message::Settings(SettingsMsg::OnlineApiKeyChanged(value)),
);
let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
.on_press(Message::Settings(SettingsMsg::RefreshOnlineModels))
.style(inputs::secondary_button)
.padding([6, 16]);
let airplane_url = inputs::labeled_input(
&t(locale, "settings.airplaneEndpointUrl"),
let airplane = ai_mode_block(
&state.airplane_ai,
AiEndpointKind::Airplane,
locale,
"http://localhost:11434/v1",
&state.airplane_endpoint_url,
|value| Message::Settings(SettingsMsg::AirplaneEndpointUrlChanged(value)),
);
let airplane_model = inputs::labeled_input(
&t(locale, "settings.airplaneEndpointModel"),
"llama3.2",
&state.airplane_endpoint_model,
|value| Message::Settings(SettingsMsg::AirplaneEndpointModelChanged(value)),
);
let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
.on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels))
.style(inputs::secondary_button)
.padding([6, 16]);
let default_model = inputs::labeled_select(
&t(locale, "settings.defaultModel"),
&model_options,
model_options
.iter()
.find(|option| option.id == state.default_model),
|option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)),
);
let title_model = inputs::labeled_select(
&t(locale, "settings.titleModel"),
&model_options,
model_options
.iter()
.find(|option| option.id == state.title_model),
|option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)),
);
let image_model = inputs::labeled_select(
&t(locale, "settings.imageAnalysisModel"),
&image_model_options,
image_model_options
.iter()
.find(|option| option.id == state.image_model),
|option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)),
);
let prompt = column![
text(t(locale, "settings.systemPrompt"))
@@ -909,30 +805,121 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
]
.spacing(8);
column![
offline,
text(t(locale, "settings.onlineEndpointSection"))
.size(12)
.color(inputs::LABEL_COLOR),
online_url,
row![online_model, online_api_key].spacing(12),
online_refresh,
text(t(locale, "settings.airplaneEndpointSection"))
.size(12)
.color(inputs::LABEL_COLOR),
airplane_url,
row![airplane_model, airplane_refresh].spacing(12),
default_model,
title_model,
image_model,
prompt,
btns,
]
column![online, airplane, prompt, btns,]
.spacing(8)
.padding([0, 16])
.into()
}
fn ai_mode_block<'a>(
state: &'a AiModeViewState,
kind: AiEndpointKind,
locale: UiLocale,
url_placeholder: &'a str,
) -> Element<'a, Message> {
let mut options = state.model_options.clone();
for model in [&state.chat_model, &state.title_model, &state.image_model] {
if !model.is_empty() && !options.iter().any(|option| option.id == *model) {
options.push(AiModelOption {
id: model.clone(),
label: model.clone(),
supports_tools: false,
supports_vision: false,
});
}
}
options.sort_by(|left, right| left.label.cmp(&right.label));
options.insert(
0,
AiModelOption {
id: String::new(),
label: t(locale, "tags.noTemplate"),
supports_tools: false,
supports_vision: false,
},
);
let selected = |id: &str| options.iter().find(|option| option.id == id);
let section_key = match kind {
AiEndpointKind::Online => "settings.onlineEndpointSection",
AiEndpointKind::Airplane => "settings.airplaneEndpointSection",
};
let url_key = match kind {
AiEndpointKind::Online => "settings.onlineEndpointUrl",
AiEndpointKind::Airplane => "settings.airplaneEndpointUrl",
};
let api_key = match kind {
AiEndpointKind::Online => "settings.onlineApiKey",
AiEndpointKind::Airplane => "settings.airplaneApiKey",
};
let keychain_placeholder = t(locale, "settings.keychainConfigured");
inputs::card(
column![
text(t(locale, section_key)).size(15),
row![
inputs::labeled_input(
&t(locale, url_key),
url_placeholder,
&state.endpoint_url,
move |value| {
Message::Settings(SettingsMsg::AiEndpointUrlChanged(kind, value))
}
),
button(text(t(locale, "settings.refreshModels")).size(13))
.on_press(Message::Settings(SettingsMsg::RefreshAiModels(kind)))
.style(inputs::secondary_button)
.padding([6, 16]),
]
.spacing(12)
.align_y(Alignment::End),
inputs::labeled_secure_input(
&t(locale, api_key),
if state.api_key_configured {
&keychain_placeholder
} else {
"sk-..."
},
&state.api_key_input,
move |value| Message::Settings(SettingsMsg::AiApiKeyChanged(kind, value)),
),
inputs::labeled_select(
&t(locale, "settings.chatModel"),
&options,
selected(&state.chat_model),
move |option| Message::Settings(SettingsMsg::AiChatModelChanged(kind, option.id)),
),
inputs::labeled_checkbox(
&t(locale, "settings.modelSupportsTools"),
state.chat_supports_tools,
move |value| Message::Settings(SettingsMsg::AiToolsChanged(kind, value)),
),
inputs::labeled_select(
&t(locale, "settings.titleModel"),
&options,
selected(&state.title_model),
move |option| Message::Settings(SettingsMsg::AiTitleModelChanged(kind, option.id)),
),
inputs::labeled_select(
&t(locale, "settings.imageAnalysisModel"),
&options,
selected(&state.image_model),
move |option| Message::Settings(SettingsMsg::AiImageModelChanged(kind, option.id)),
),
inputs::labeled_checkbox(
&t(locale, "settings.modelSupportsVision"),
state.image_supports_vision,
move |value| Message::Settings(SettingsMsg::AiVisionChanged(kind, value)),
),
button(text(t(locale, "settings.testChat")).size(13))
.on_press(Message::Settings(SettingsMsg::TestAi(kind)))
.style(inputs::secondary_button)
.padding([6, 16]),
]
.spacing(8),
)
.into()
}
fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
column![
text(t(locale, "settings.luaRuntimeOnly"))

View File

@@ -649,14 +649,14 @@ fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool)
return false;
};
if offline_mode {
!state.airplane_endpoint_url.trim().is_empty()
&& !state.airplane_endpoint_model.trim().is_empty()
let mode = if offline_mode {
&state.airplane_ai
} else {
!state.online_endpoint_url.trim().is_empty()
&& !state.online_endpoint_model.trim().is_empty()
&& (state.online_api_key_configured || !state.online_api_key_input.trim().is_empty())
}
&state.online_ai
};
!mode.endpoint_url.trim().is_empty()
&& !mode.chat_model.trim().is_empty()
&& (offline_mode || mode.api_key_configured || !mode.api_key_input.trim().is_empty())
}
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
@@ -965,14 +965,14 @@ mod tests {
assert!(!is_ai_enabled(Some(&settings), false));
assert!(!is_ai_enabled(Some(&settings), true));
settings.online_endpoint_url = "https://api.example.com/v1".to_string();
settings.online_endpoint_model = "gpt-4.1-mini".to_string();
settings.online_api_key_configured = true;
settings.online_ai.endpoint_url = "https://api.example.com/v1".to_string();
settings.online_ai.chat_model = "gpt-4.1-mini".to_string();
settings.online_ai.api_key_configured = true;
assert!(is_ai_enabled(Some(&settings), false));
assert!(!is_ai_enabled(Some(&settings), true));
settings.airplane_endpoint_url = "http://localhost:11434/v1".to_string();
settings.airplane_endpoint_model = "llama3.2".to_string();
settings.airplane_ai.endpoint_url = "http://localhost:11434/v1".to_string();
settings.airplane_ai.chat_model = "llama3.2".to_string();
assert!(is_ai_enabled(Some(&settings), true));
}
}

View File

@@ -27,6 +27,7 @@ fn dialog_keys_exist_in_all_locales() {
#[test]
fn toast_keys_exist_in_all_locales() {
let keys = [
"editor.saved",
"projectSelector.toast.switched",
"projectSelector.toast.switchFailed",
"projectSelector.toast.created",

View File

@@ -119,6 +119,7 @@ chat-new = Neue Unterhaltung
chat-newWithModel = Unterhaltung mit { $model }
chat-unavailable-title = Die dialogbasierte KI ist nicht verfügbar
chat-unavailable-guidance = Konfiguriere den aktiven KI-Endpunkt und das Modell in den Einstellungen. Im Flugmodus wird nur der lokale Endpunkt verwendet.
chat-providerError = Fehler des KI-Anbieters:
chat-unavailable-openSettings = KI-Einstellungen öffnen
chat-model-none = Kein Modell ausgewählt
chat-model-label = Modell
@@ -372,6 +373,7 @@ sidebar-filter-tags = Tags
sidebar-filter-categories = Kategorien
sidebar-filter-calendar = Archiv
sidebar-filter-noResults = Keine Treffer
editor-saved = Gespeichert.
editor-title = Titel
editor-titlePlaceholder = Titel eingeben...
editor-slug = Slug
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Online-Endpunkt
settings-onlineEndpointUrl = URL des Online-Endpunkts
settings-onlineEndpointModel = Modell des Online-Endpunkts
settings-onlineApiKey = Online-API-Schlüssel
settings-airplaneApiKey = Lokaler API-Schlüssel (optional)
settings-airplaneEndpointSection = Flugmodus-Endpunkt
settings-airplaneEndpointUrl = URL des Flugmodus-Endpunkts
settings-airplaneEndpointModel = Modell des Flugmodus-Endpunkts
settings-refreshModels = Modelle aktualisieren
settings-chatModel = Chat-Modell
settings-modelSupportsTools = Ausgewähltes Chat-Modell unterstützt Werkzeugaufrufe
settings-modelSupportsVision = Ausgewähltes Bildmodell unterstützt Bilderkennung
settings-testChat = Chat testen
settings-modelsLoaded = Modelle geladen.
settings-testChatSuccess = Die ausgewählten Modelle haben erfolgreich geantwortet.
settings-onlineChatModel = Online-Chat-Modell
settings-onlineTitleModel = Online-Titelmodell
settings-onlineImageModel = Online-Bildmodell
settings-onlineModelSupportsTools = Online-Chat-Modell unterstützt Werkzeugaufrufe
settings-onlineModelSupportsVision = Online-Bildmodell unterstützt Bilderkennung
settings-airplaneChatModel = Lokales Chat-Modell
settings-airplaneTitleModel = Lokales Titelmodell
settings-airplaneImageModel = Lokales Bildmodell
settings-airplaneModelSupportsTools = Lokales Chat-Modell unterstützt Werkzeugaufrufe
settings-airplaneModelSupportsVision = Lokales Bildmodell unterstützt Bilderkennung
settings-defaultModel = Standardmodell
settings-titleModel = Titelmodell
settings-imageAnalysisModel = Bildanalysemodell

View File

@@ -119,6 +119,7 @@ chat-new = New Chat
chat-newWithModel = Chat with { $model }
chat-unavailable-title = Conversational AI is unavailable
chat-unavailable-guidance = Configure the active AI endpoint and model in Settings. Airplane mode uses only the local endpoint.
chat-providerError = AI provider error:
chat-unavailable-openSettings = Open AI Settings
chat-model-none = No model selected
chat-model-label = Model
@@ -363,6 +364,7 @@ sidebar-filter-tags = Tags
sidebar-filter-categories = Categories
sidebar-filter-calendar = Archive
sidebar-filter-noResults = No matching items
editor-saved = Saved.
editor-title = Title
editor-titlePlaceholder = Enter title...
editor-slug = Slug
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Online Endpoint
settings-onlineEndpointUrl = Online Endpoint URL
settings-onlineEndpointModel = Online Endpoint Model
settings-onlineApiKey = Online API Key
settings-airplaneApiKey = Local API Key (Optional)
settings-airplaneEndpointSection = Airplane Endpoint
settings-airplaneEndpointUrl = Airplane Endpoint URL
settings-airplaneEndpointModel = Airplane Endpoint Model
settings-refreshModels = Refresh Models
settings-chatModel = Chat Model
settings-modelSupportsTools = Selected chat model supports tool calls
settings-modelSupportsVision = Selected image model supports vision
settings-testChat = Test Chat
settings-modelsLoaded = Models loaded.
settings-testChatSuccess = Selected models responded successfully.
settings-onlineChatModel = Online Chat Model
settings-onlineTitleModel = Online Title Model
settings-onlineImageModel = Online Image Model
settings-onlineModelSupportsTools = Online chat model supports tool calls
settings-onlineModelSupportsVision = Online image model supports vision
settings-airplaneChatModel = Local Chat Model
settings-airplaneTitleModel = Local Title Model
settings-airplaneImageModel = Local Image Model
settings-airplaneModelSupportsTools = Local chat model supports tool calls
settings-airplaneModelSupportsVision = Local image model supports vision
settings-defaultModel = Default Model
settings-titleModel = Title Model
settings-imageAnalysisModel = Image Analysis Model

View File

@@ -119,6 +119,7 @@ chat-new = Nueva conversación
chat-newWithModel = Conversación con { $model }
chat-unavailable-title = La IA conversacional no está disponible
chat-unavailable-guidance = Configura el punto de acceso de IA activo y el modelo en Ajustes. El modo avión usa únicamente el punto de acceso local.
chat-providerError = Error del proveedor de IA:
chat-unavailable-openSettings = Abrir ajustes de IA
chat-model-none = Ningún modelo seleccionado
chat-model-label = Modelo
@@ -372,6 +373,7 @@ sidebar-filter-tags = Etiquetas
sidebar-filter-categories = Categorías
sidebar-filter-calendar = Archivo
sidebar-filter-noResults = Sin resultados
editor-saved = Guardado.
editor-title = Título
editor-titlePlaceholder = Introducir título...
editor-slug = Slug
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Extremo en línea
settings-onlineEndpointUrl = URL del extremo en línea
settings-onlineEndpointModel = Modelo del extremo en línea
settings-onlineApiKey = Clave API en línea
settings-airplaneApiKey = Clave API local (opcional)
settings-airplaneEndpointSection = Extremo de modo avión
settings-airplaneEndpointUrl = URL del extremo de modo avión
settings-airplaneEndpointModel = Modelo del extremo de modo avión
settings-refreshModels = Actualizar modelos
settings-chatModel = Modelo de chat
settings-modelSupportsTools = El modelo de chat seleccionado admite llamadas a herramientas
settings-modelSupportsVision = El modelo de imagen seleccionado admite visión
settings-testChat = Probar chat
settings-modelsLoaded = Modelos cargados.
settings-testChatSuccess = Los modelos seleccionados respondieron correctamente.
settings-onlineChatModel = Modelo de chat en línea
settings-onlineTitleModel = Modelo de títulos en línea
settings-onlineImageModel = Modelo de imágenes en línea
settings-onlineModelSupportsTools = El modelo de chat en línea admite llamadas a herramientas
settings-onlineModelSupportsVision = El modelo de imágenes en línea admite visión
settings-airplaneChatModel = Modelo de chat local
settings-airplaneTitleModel = Modelo de títulos local
settings-airplaneImageModel = Modelo de imágenes local
settings-airplaneModelSupportsTools = El modelo de chat local admite llamadas a herramientas
settings-airplaneModelSupportsVision = El modelo de imágenes local admite visión
settings-defaultModel = Modelo predeterminado
settings-titleModel = Modelo de títulos
settings-imageAnalysisModel = Modelo de análisis de imágenes

View File

@@ -119,6 +119,7 @@ chat-new = Nouvelle conversation
chat-newWithModel = Conversation avec { $model }
chat-unavailable-title = LIA conversationnelle est indisponible
chat-unavailable-guidance = Configurez le point daccès IA actif et le modèle dans les réglages. Le mode avion utilise uniquement le point daccès local.
chat-providerError = Erreur du fournisseur dIA :
chat-unavailable-openSettings = Ouvrir les réglages IA
chat-model-none = Aucun modèle sélectionné
chat-model-label = Modèle
@@ -372,6 +373,7 @@ sidebar-filter-tags = Tags
sidebar-filter-categories = Catégories
sidebar-filter-calendar = Archives
sidebar-filter-noResults = Aucun résultat
editor-saved = Enregistré.
editor-title = Titre
editor-titlePlaceholder = Saisir le titre...
editor-slug = Slug
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Point de terminaison en ligne
settings-onlineEndpointUrl = URL du point de terminaison en ligne
settings-onlineEndpointModel = Modèle du point de terminaison en ligne
settings-onlineApiKey = Clé API en ligne
settings-airplaneApiKey = Clé API locale (facultative)
settings-airplaneEndpointSection = Point de terminaison mode avion
settings-airplaneEndpointUrl = URL du point de terminaison mode avion
settings-airplaneEndpointModel = Modèle du point de terminaison mode avion
settings-refreshModels = Rafraîchir les modèles
settings-chatModel = Modèle de discussion
settings-modelSupportsTools = Le modèle de discussion sélectionné prend en charge les appels doutils
settings-modelSupportsVision = Le modèle dimage sélectionné prend en charge la vision
settings-testChat = Tester la discussion
settings-modelsLoaded = Modèles chargés.
settings-testChatSuccess = Les modèles sélectionnés ont répondu correctement.
settings-onlineChatModel = Modèle de discussion en ligne
settings-onlineTitleModel = Modèle de titre en ligne
settings-onlineImageModel = Modèle dimage en ligne
settings-onlineModelSupportsTools = Le modèle de discussion en ligne prend en charge les appels doutils
settings-onlineModelSupportsVision = Le modèle dimage en ligne prend en charge la vision
settings-airplaneChatModel = Modèle de discussion local
settings-airplaneTitleModel = Modèle de titre local
settings-airplaneImageModel = Modèle dimage local
settings-airplaneModelSupportsTools = Le modèle de discussion local prend en charge les appels doutils
settings-airplaneModelSupportsVision = Le modèle dimage local prend en charge la vision
settings-defaultModel = Modèle par défaut
settings-titleModel = Modèle de titres
settings-imageAnalysisModel = Modèle d'analyse d'image

View File

@@ -119,6 +119,7 @@ chat-new = Nuova conversazione
chat-newWithModel = Conversazione con { $model }
chat-unavailable-title = LIA conversazionale non è disponibile
chat-unavailable-guidance = Configura lendpoint IA attivo e il modello nelle impostazioni. La modalità aereo usa solo lendpoint locale.
chat-providerError = Errore del provider IA:
chat-unavailable-openSettings = Apri impostazioni IA
chat-model-none = Nessun modello selezionato
chat-model-label = Modello
@@ -372,6 +373,7 @@ sidebar-filter-tags = Tag
sidebar-filter-categories = Categorie
sidebar-filter-calendar = Archivio
sidebar-filter-noResults = Nessun risultato
editor-saved = Salvato.
editor-title = Titolo
editor-titlePlaceholder = Inserisci titolo...
editor-slug = Slug
@@ -489,10 +491,27 @@ settings-onlineEndpointSection = Endpoint online
settings-onlineEndpointUrl = URL endpoint online
settings-onlineEndpointModel = Modello endpoint online
settings-onlineApiKey = Chiave API online
settings-airplaneApiKey = Chiave API locale (facoltativa)
settings-airplaneEndpointSection = Endpoint modalità aereo
settings-airplaneEndpointUrl = URL endpoint modalità aereo
settings-airplaneEndpointModel = Modello endpoint modalità aereo
settings-refreshModels = Aggiorna modelli
settings-chatModel = Modello chat
settings-modelSupportsTools = Il modello chat selezionato supporta le chiamate agli strumenti
settings-modelSupportsVision = Il modello immagini selezionato supporta la visione
settings-testChat = Prova chat
settings-modelsLoaded = Modelli caricati.
settings-testChatSuccess = I modelli selezionati hanno risposto correttamente.
settings-onlineChatModel = Modello chat online
settings-onlineTitleModel = Modello titoli online
settings-onlineImageModel = Modello immagini online
settings-onlineModelSupportsTools = Il modello chat online supporta le chiamate agli strumenti
settings-onlineModelSupportsVision = Il modello immagini online supporta la visione
settings-airplaneChatModel = Modello chat locale
settings-airplaneTitleModel = Modello titoli locale
settings-airplaneImageModel = Modello immagini locale
settings-airplaneModelSupportsTools = Il modello chat locale supporta le chiamate agli strumenti
settings-airplaneModelSupportsVision = Il modello immagini locale supporta la visione
settings-defaultModel = Modello predefinito
settings-titleModel = Modello titoli
settings-imageAnalysisModel = Modello analisi immagini

View File

@@ -12,8 +12,12 @@ use "./media.allium" as media
entity AiEndpoint {
kind: online | airplane
url: String
api_key: String? -- encrypted via SecureKeyStore; null for local models
model: String
api_key: String? -- encrypted via SecureKeyStore; required online, optional locally
model: String -- chat model
title_model: String?
image_model: String?
chat_supports_tools: Boolean? -- operator override of discovered capability
image_supports_vision: Boolean? -- operator override of discovered capability
-- online: cloud provider (OpenAI, Anthropic-via-proxy, etc.)
-- airplane: local model (Ollama, LM Studio, etc.)
}
@@ -81,6 +85,10 @@ surface AiEndpointSurface {
endpoint.url
endpoint.api_key when endpoint.api_key != null
endpoint.model
endpoint.title_model when endpoint.title_model != null
endpoint.image_model when endpoint.image_model != null
endpoint.chat_supports_tools when endpoint.chat_supports_tools != null
endpoint.image_supports_vision when endpoint.image_supports_vision != null
}
surface AiModelSurface {
@@ -177,8 +185,10 @@ surface AiConfigurationSurface {
facing _: AiOperator
provides:
SetAiEndpointRequested(kind, url, api_key, model)
SetAiEndpointRequested(kind, url, api_key, model, title_model, image_model, chat_supports_tools, image_supports_vision)
RemoveAiEndpointRequested(kind)
RefreshEndpointModelsRequested(kind, url, api_key)
TestEndpointModelsRequested(kind)
RefreshModelCatalogRequested(source)
}
@@ -221,13 +231,17 @@ config {
}
rule SetAiEndpoint {
when: SetAiEndpointRequested(kind, url, api_key, model)
when: SetAiEndpointRequested(kind, url, api_key, model, title_model, image_model, chat_supports_tools, image_supports_vision)
ensures:
let endpoint = AiEndpoint.created(
kind: kind,
url: url,
api_key: api_key,
model: model
model: model,
title_model: title_model,
image_model: image_model,
chat_supports_tools: chat_supports_tools,
image_supports_vision: image_supports_vision
)
endpoint.kind = kind
}
@@ -335,6 +349,22 @@ rule RefreshModelCatalog {
ensures: ModelCatalogUpdated()
}
rule RefreshEndpointModels {
when: RefreshEndpointModelsRequested(kind, url, api_key)
requires: url != ""
requires: kind = airplane or api_key != null
-- Discovery calls GET /models and does not require a selected model.
ensures: EndpointModelsLoaded(kind)
}
rule TestEndpointModels {
when: TestEndpointModelsRequested(kind)
requires: exists endpoint in AiEndpoints where endpoint.kind = kind
-- Sends a minimal chat-completions request to each distinct configured
-- chat, title, and image model for this endpoint.
ensures: EndpointModelsResponded(kind)
}
invariant AirplaneModeGating {
-- Endpoint routing based on airplane (offline) mode:
-- airplane_mode = true -> use airplane endpoint (local model)
@@ -347,8 +377,8 @@ invariant AirplaneModeGating {
invariant AirplaneModeModelSwap {
-- In airplane mode, cloud models are never contacted.
-- Chat uses the configured offline chat model when needed.
-- Image analysis uses the configured offline vision-capable model when needed.
-- Chat, title generation, and image analysis use the models and capability
-- overrides configured for the active endpoint profile.
-- If no suitable offline model is configured, the operation fails with
-- actionable guidance instead of silently falling back to the online endpoint.
}
@@ -356,9 +386,11 @@ invariant AirplaneModeModelSwap {
invariant TwoEndpointModel {
-- Two configurable OpenAI-compatible endpoints:
-- online: for cloud providers (requires API key)
-- airplane: for local models (no API key required)
-- airplane: for local models (API key optional)
-- Both use the OpenAI Chat Completions wire format.
-- Endpoint selection is configurable rather than tied to hard-coded providers.
-- URL, credentials, chat/title/image models, and capability overrides are
-- independent. Endpoint selection is controlled only by the status-bar
-- airplane-mode switch, not by a duplicate Settings toggle.
}
invariant AdvisoryModelCatalog {
@@ -387,6 +419,12 @@ invariant VisionCapabilityGate {
-- before the runtime sends multimodal requests to them.
}
invariant OperatorCapabilityOverrides {
-- Discovered tool and vision capabilities prefill the profile controls.
-- Explicit operator overrides take precedence at runtime so models with
-- incomplete metadata can enable or disable tools and image input safely.
}
invariant ChatContextTruncation {
-- Chat requests are trimmed to fit within the selected model's context window.
-- Oldest user/assistant pairs are dropped first.

View File

@@ -53,17 +53,21 @@ value SettingsCategoryRow {
}
value SettingsAISection {
anthropic_api_key: String? -- masked + Change/Save
mistral_api_key: String? -- masked + Change/Save
ollama_enabled: Boolean -- toggle, shows model capabilities table (tools/vision)
lm_studio_enabled: Boolean -- toggle, shows model capabilities table
offline_mode: Boolean -- toggle, switches between online and airplane endpoint
default_model: String? -- select from active endpoint models
title_model: String? -- select
image_analysis_model: String? -- select (vision-capable only)
online: SettingsAIEndpoint
airplane: SettingsAIEndpoint
system_prompt: String -- textarea (12 rows) + Save + Reset to Default
}
value SettingsAIEndpoint {
url: String
api_key: String? -- masked; optional for airplane endpoint
chat_model: String?
title_model: String?
image_model: String?
chat_supports_tools: Boolean
image_supports_vision: Boolean
}
value SettingsTechnologySection {
semantic_similarity_enabled: Boolean -- checkbox: enable duplicate search + related-post embeddings
-- Scripting runtime is fixed at the application layer; no runtime switch is exposed.
@@ -130,8 +134,9 @@ surface SettingsViewSurface {
SettingsCategoryUpdated(category_row)
SettingsCategoryRemoved(category_name)
SettingsCategoriesResetToDefaults()
SettingsAIApiKeySaved(provider, key)
SettingsAIModelRefreshRequested(endpoint)
SettingsAIProfileSaved(kind, endpoint)
SettingsAIModelRefreshRequested(kind, url, api_key)
SettingsAIChatTestRequested(kind)
SettingsAISystemPromptSaved(prompt)
SettingsAISystemPromptReset()
SettingsPublishingSaved(publishing_data)
@@ -179,23 +184,27 @@ surface SettingsViewSurface {
-- "Reset to Defaults" restores default categories set.
@guarantee AISection
-- Section 4: Anthropic API key, Mistral API key (both masked + Change/Save),
-- Ollama toggle (with model capabilities table: tools/vision),
-- LM Studio toggle (with capabilities table),
-- Offline mode toggle (with dedicated model selectors for chat/title/image),
-- Default model (with catalog info: max output, context window),
-- Title model, Image analysis model,
-- Section 4: independent Online and Airplane endpoint cards. Each has
-- URL, masked API key (optional locally), Refresh Models, chat/title/image
-- selectors, explicit tools/vision capability overrides, and Test Chat.
-- Settings has no airplane-mode toggle; the status-bar switch selects
-- the active profile and immediately swaps chat/model routing.
-- System prompt (textarea config.settings_system_prompt_rows rows + Save + Reset).
@guarantee AIApiKeyValidation
-- On Save: test API call to endpoint before persisting.
-- On failure: toast error message, key not saved.
-- On success: key encrypted via SecureKeyStore, masked display shown.
-- API keys are encrypted via SecureKeyStore and displayed masked.
-- Online profiles require a key; airplane profiles permit authenticated
-- or unauthenticated local OpenAI-compatible endpoints.
@guarantee AIModelRefresh
-- Refresh Models button per endpoint fetches model list from URL.
-- Model selector populated from fetched list.
-- Per-model info: max output tokens, context window (when available).
-- Discovery requires URL and online credentials but no preselected model.
-- All three role selectors are populated independently from the result;
-- advertised capability metadata prefills tools and vision overrides.
@guarantee AIChatTest
-- Test Chat sends a minimal request through every distinct model selected
-- for chat, title generation, or image analysis in the chosen profile.
@guarantee TechnologySection
-- Section 5: Semantic Similarity toggle